Deploy AIOS web (React glide grid + FastAPI slice)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- RELEASES.json +1 -1
- VERSION +1 -1
- api/ai_enrich.py +44 -14
- api/ai_review.py +61 -11
- api/automation_engine.py +508 -16
- api/main.py +54 -0
- api/routes_automation.py +59 -3
- api/routes_feedback.py +168 -0
- api/routes_keychain.py +108 -0
- api/routes_nav.py +0 -0
- api/routes_query.py +208 -7
- api/routes_starred.py +828 -0
- api/routes_statements.py +104 -3
- api/routes_tables.py +8 -2
- api/routes_usage.py +51 -0
- api/usage_ledger.py +281 -0
- platform/core/keychain.py +46 -0
- web/src/account/FeedbackPage.tsx +246 -0
- web/src/account/SubscriptionPage.tsx +47 -0
- web/src/account/UsagePage.tsx +297 -0
- web/src/account/account.css +372 -0
- web/src/account/accountApi.ts +188 -0
- web/src/apiContract.ts +18 -0
- web/src/assistant/AssistantPage.tsx +9 -0
- web/src/automation/AutomationDetail.tsx +141 -0
- web/src/automation/automation.css +68 -0
- web/src/automation/automationApi.ts +140 -4
- web/src/customer-grid/CustomerGrid.tsx +346 -90
- web/src/customer-grid/RecordDetail.tsx +39 -0
- web/src/customer-grid/ViewSidebar.tsx +48 -112
- web/src/customer-grid/counts.ts +49 -0
- web/src/customer-grid/overlayPlacement.ts +46 -0
- web/src/customer-grid/queryPreview.ts +23 -3
- web/src/customer-grid/recordStars.ts +187 -0
- web/src/customer-grid/rowStar.css +76 -0
- web/src/customer-grid/types.ts +30 -2
- web/src/home/HomePage.tsx +179 -15
- web/src/index.css +201 -108
- web/src/query/QueryPage.tsx +9 -6
- web/src/query/query.css +45 -45
- web/src/query/queryApi.ts +65 -4
- web/src/query/queryParts.tsx +147 -35
- web/src/settings/AdminPane.tsx +646 -646
- web/src/settings/PermsEditor.tsx +6 -6
- web/src/settings/SettingsModal.tsx +0 -0
- web/src/settings/permsModel.ts +50 -10
- web/src/shell/NavExtras.tsx +986 -995
- web/src/shell/Shell.tsx +0 -0
- web/src/shell/nav.ts +38 -0
- web/src/shell/navExtras.css +676 -704
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "2b8e675",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
2b8e675
|
api/ai_enrich.py
CHANGED
|
@@ -205,19 +205,16 @@ def _usage_tokens(body):
|
|
| 205 |
β RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
|
| 206 |
rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
|
| 207 |
run than happened, which is exactly the cost-surprise complaint R13 cites.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
"""
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
return None
|
| 212 |
-
for key in ('total_tokens', 'totalTokens'):
|
| 213 |
-
got = usage.get(key)
|
| 214 |
-
if isinstance(got, int) and not isinstance(got, bool):
|
| 215 |
-
return got
|
| 216 |
-
ins = usage.get('prompt_tokens', usage.get('input_tokens'))
|
| 217 |
-
outs = usage.get('completion_tokens', usage.get('output_tokens'))
|
| 218 |
-
if isinstance(ins, int) and isinstance(outs, int):
|
| 219 |
-
return ins + outs
|
| 220 |
-
return None
|
| 221 |
|
| 222 |
|
| 223 |
#: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
|
|
@@ -372,7 +369,7 @@ def scheduled_fields(defn):
|
|
| 372 |
|
| 373 |
|
| 374 |
def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
|
| 375 |
-
timeout=None, ask=None):
|
| 376 |
"""Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
|
| 377 |
|
| 378 |
Returns a REPORT and never raises for a vendor problem:
|
|
@@ -391,9 +388,17 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
|
|
| 391 |
|
| 392 |
β `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
|
| 393 |
with no network and no vendor bill. It defaults to the real `_ask`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
"""
|
| 395 |
import ai_review
|
| 396 |
import core.user_tables as ut
|
|
|
|
| 397 |
|
| 398 |
defn = ut.get(table_key, st=st) or {}
|
| 399 |
field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
|
|
@@ -440,7 +445,11 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
|
|
| 440 |
keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
|
| 441 |
report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
|
| 442 |
'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
|
| 443 |
-
'provider': '', 'model': '', 'problem': ''
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
if not plan['run']:
|
| 445 |
return report
|
| 446 |
|
|
@@ -485,8 +494,14 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
|
|
| 485 |
continue
|
| 486 |
answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
|
| 487 |
int(cfg.get('maxTokens') or 300), tmo)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
if isinstance(tokens, int) and not isinstance(tokens, bool):
|
| 489 |
report['tokens'] += tokens
|
|
|
|
|
|
|
| 490 |
if problem or not answer or answer.strip().upper() == 'UNKNOWN':
|
| 491 |
# β THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
|
| 492 |
# changes, so a failed run never destroys a value it could not replace.
|
|
@@ -517,4 +532,19 @@ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, bu
|
|
| 517 |
if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
|
| 518 |
report['limit'] = ceiling_report(report['tokens'], ceiling,
|
| 519 |
report['filled'], report['planned'])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
return report
|
|
|
|
| 205 |
β RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
|
| 206 |
rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
|
| 207 |
run than happened, which is exactly the cost-surprise complaint R13 cites.
|
| 208 |
+
|
| 209 |
+
ββ W35-T41 / C7 β THE READER MOVED AND THIS IS NOW ONE LINE OVER IT. `usage_ledger.total_from`
|
| 210 |
+
is the same logic, in the module that owns the meter; the alternative was a second reader in a
|
| 211 |
+
second file answering the same question about the same key, which is the shape this repo pays
|
| 212 |
+
for most often ([[one-question-two-normalizers]]). β The NAME and the None-semantics stay
|
| 213 |
+
exactly as wave 34 wrote them, because `run_field`'s ceiling arithmetic reads this and a gate
|
| 214 |
+
injects against its shape β lifting the body, not the signature.
|
| 215 |
"""
|
| 216 |
+
import usage_ledger
|
| 217 |
+
return usage_ledger.total_from(body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
#: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
|
| 372 |
+
timeout=None, ask=None, user=''):
|
| 373 |
"""Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
|
| 374 |
|
| 375 |
Returns a REPORT and never raises for a vendor problem:
|
|
|
|
| 388 |
|
| 389 |
β `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
|
| 390 |
with no network and no vendor bill. It defaults to the real `_ask`.
|
| 391 |
+
|
| 392 |
+
ββ W35-T41 / C7 β ONE USAGE-LEDGER LINE PER RUN, NOT PER ROW, and the arithmetic is the reason:
|
| 393 |
+
this loop makes one LLM call per row, so a ledger write per call would be hundreds of store
|
| 394 |
+
writes for one click. The run already accumulates `report['tokens']`; `calls` and `unmeasured`
|
| 395 |
+
join it so the meter can say "40 calls, 12,000 tokens, 3 of them unmeasured" instead of a total
|
| 396 |
+
with an unknown shortfall. `user` is the ledger's attribution and is passed by this file's own
|
| 397 |
+
callers in `routes_tables`.
|
| 398 |
"""
|
| 399 |
import ai_review
|
| 400 |
import core.user_tables as ut
|
| 401 |
+
import usage_ledger
|
| 402 |
|
| 403 |
defn = ut.get(table_key, st=st) or {}
|
| 404 |
field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
|
|
|
|
| 445 |
keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
|
| 446 |
report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
|
| 447 |
'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
|
| 448 |
+
'provider': '', 'model': '', 'problem': '',
|
| 449 |
+
# β C7's two counters. `calls` is what the meter bills; `unmeasured` is how many of
|
| 450 |
+
# them the provider declined to report tokens for, so a total is never presented as
|
| 451 |
+
# complete when it is a floor.
|
| 452 |
+
'calls': 0, 'unmeasured': 0}
|
| 453 |
if not plan['run']:
|
| 454 |
return report
|
| 455 |
|
|
|
|
| 494 |
continue
|
| 495 |
answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
|
| 496 |
int(cfg.get('maxTokens') or 300), tmo)
|
| 497 |
+
# β COUNTED HERE, ON EVERY OUTCOME β before the `problem` branch below, because a call that
|
| 498 |
+
# errored or answered UNKNOWN still reached the vendor and is still billed. A meter that
|
| 499 |
+
# counts only filled cells reports a cheap run for one that spent its whole budget failing.
|
| 500 |
+
report['calls'] += 1
|
| 501 |
if isinstance(tokens, int) and not isinstance(tokens, bool):
|
| 502 |
report['tokens'] += tokens
|
| 503 |
+
else:
|
| 504 |
+
report['unmeasured'] += 1
|
| 505 |
if problem or not answer or answer.strip().upper() == 'UNKNOWN':
|
| 506 |
# β THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
|
| 507 |
# changes, so a failed run never destroys a value it could not replace.
|
|
|
|
| 532 |
if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
|
| 533 |
report['limit'] = ceiling_report(report['tokens'], ceiling,
|
| 534 |
report['filled'], report['planned'])
|
| 535 |
+
# ββ C7's LEDGER LINE β one per run, after both store writes, and only when the run actually
|
| 536 |
+
# called something. `total=` rather than a split: `_ask`'s contract returns a total and a gate
|
| 537 |
+
# injects against that shape, so splitting it here would mean inventing two numbers from one.
|
| 538 |
+
#
|
| 539 |
+
# β TWO LINES WHEN A RUN HAS BOTH KINDS, and it is not tidiness. `record` marks a line
|
| 540 |
+
# `unmeasured` only when its total is None, so ONE line carrying 40 calls and a partial total
|
| 541 |
+
# would book all forty as measured and lose the shortfall β a total presented as complete when
|
| 542 |
+
# it is a floor, which is the exact failure `_usage_tokens`' None-semantics exists to prevent.
|
| 543 |
+
_measured = report['calls'] - report['unmeasured']
|
| 544 |
+
if _measured > 0:
|
| 545 |
+
usage_ledger.record('field_agent', report['provider'], report['model'],
|
| 546 |
+
total=report['tokens'], calls=_measured, st=st, user=user)
|
| 547 |
+
if report['unmeasured'] > 0:
|
| 548 |
+
usage_ledger.record('field_agent', report['provider'], report['model'],
|
| 549 |
+
total=None, calls=report['unmeasured'], st=st, user=user)
|
| 550 |
return report
|
api/ai_review.py
CHANGED
|
@@ -130,6 +130,17 @@ def _parse(text, options):
|
|
| 130 |
return "", reason
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def _call_openai(p, model, system, user, timeout):
|
| 134 |
r = requests.post(p["url"], timeout=timeout,
|
| 135 |
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
|
|
@@ -138,12 +149,14 @@ def _call_openai(p, model, system, user, timeout):
|
|
| 138 |
"messages": [{"role": "system", "content": system},
|
| 139 |
{"role": "user", "content": user}]})
|
| 140 |
if r.status_code >= 400:
|
| 141 |
-
|
|
|
|
|
|
|
| 142 |
body = r.json()
|
| 143 |
choices = body.get("choices") or []
|
| 144 |
if not choices:
|
| 145 |
-
return "", f"{p['name']} returned no choices"
|
| 146 |
-
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), ""
|
| 147 |
|
| 148 |
|
| 149 |
def _call_anthropic(p, model, system, user, timeout):
|
|
@@ -154,25 +167,34 @@ def _call_anthropic(p, model, system, user, timeout):
|
|
| 154 |
json={"model": model, "max_tokens": 300, "system": system,
|
| 155 |
"messages": [{"role": "user", "content": user}]})
|
| 156 |
if r.status_code >= 400:
|
| 157 |
-
return "", f"anthropic answered {r.status_code}"
|
| 158 |
body = r.json()
|
| 159 |
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
|
| 160 |
# reading content[0] before this check turns a refusal into an IndexError inside a run.
|
|
|
|
| 161 |
if body.get("stop_reason") == "refusal":
|
| 162 |
-
return "", "anthropic declined to answer this record"
|
| 163 |
parts = [b.get("text") or "" for b in (body.get("content") or [])
|
| 164 |
if isinstance(b, dict) and b.get("type") == "text"]
|
| 165 |
if not parts:
|
| 166 |
-
return "", "anthropic returned no text"
|
| 167 |
-
return "".join(parts), ""
|
| 168 |
|
| 169 |
|
| 170 |
-
def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
|
| 171 |
"""Pick this record's next stage. Returns `(choice, meta)`.
|
| 172 |
|
| 173 |
`choice` is "" whenever a person should decide β which is every failure mode there is.
|
| 174 |
`meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
"""
|
|
|
|
| 176 |
opts = [str(o) for o in (options or []) if str(o).strip()]
|
| 177 |
if not opts:
|
| 178 |
return "", {"problem": "the review offers no next stages"}
|
|
@@ -188,11 +210,22 @@ def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
|
|
| 188 |
problems = []
|
| 189 |
for p in live:
|
| 190 |
model = override or p["model"]
|
|
|
|
| 191 |
try:
|
| 192 |
-
text, err = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
|
| 193 |
p, model, system, user, tmo)
|
| 194 |
except Exception as e: # noqa: BLE001
|
| 195 |
text, err = "", f"{p['name']} failed: {type(e).__name__}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
if err:
|
| 197 |
problems.append(err)
|
| 198 |
continue # ladder: a dead provider degrades to the next one
|
|
@@ -387,12 +420,19 @@ def _flow_from_tool_call(obj):
|
|
| 387 |
"actions": out}, ""
|
| 388 |
|
| 389 |
|
| 390 |
-
def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None
|
|
|
|
| 391 |
"""A sentence -> `(draft, refusal_sentence, provider)`. β NOTHING IS SAVED HERE.
|
| 392 |
|
| 393 |
Exactly one of `draft` and `refusal_sentence` is truthy β the same contract
|
| 394 |
`web_agent.run_step` keeps, so a caller has no third case to get wrong.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
"""
|
|
|
|
| 396 |
text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
|
| 397 |
if not text:
|
| 398 |
return None, "type what you want the automation to do", None
|
|
@@ -444,12 +484,22 @@ def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeou
|
|
| 444 |
problems.append(f"{p['name']}: HTTP {r.status_code}")
|
| 445 |
continue
|
| 446 |
try:
|
| 447 |
-
|
|
|
|
| 448 |
.get("tool_calls") or [])
|
| 449 |
args = json.loads(calls[0]["function"]["arguments"]) if calls else None
|
| 450 |
except Exception as e: # noqa: BLE001
|
| 451 |
problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
|
| 452 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
draft, refusal = _flow_from_tool_call(args)
|
| 454 |
if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
|
| 455 |
# β A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
|
|
|
|
| 130 |
return "", reason
|
| 131 |
|
| 132 |
|
| 133 |
+
# ββ WAVE 35 Β· W35-T41 / C7 β THE TWO TRANSPORTS RETURN THE RESPONSE BODY'S `usage`.
|
| 134 |
+
#
|
| 135 |
+
# β THIS FILE'S OWN HEADER USED TO BE THE PRODUCT'S CONFESSION THAT NOTHING COUNTED: `ai_enrich`'s
|
| 136 |
+
# docstring says *"`ai_review.decide` -- the product's only other LLM entry -- has no token
|
| 137 |
+
# accounting of ANY kind"*. R9 asks for ONE meter for every AI surface, so the counting has to reach
|
| 138 |
+
# this transport rather than be bolted onto its caller β the caller never sees the body.
|
| 139 |
+
#
|
| 140 |
+
# β A THIRD RETURN VALUE, NOT A MUTATED ARGUMENT, and both call sites are in `decide` below. The
|
| 141 |
+
# tuple grew from `(text, err)` to `(text, err, usage)`; `usage` is the raw `usage` OBJECT (or None),
|
| 142 |
+
# because reading it is `usage_ledger`'s job and a second reader here would be the two-normalizers
|
| 143 |
+
# shape this repo keeps paying for.
|
| 144 |
def _call_openai(p, model, system, user, timeout):
|
| 145 |
r = requests.post(p["url"], timeout=timeout,
|
| 146 |
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
|
|
|
|
| 149 |
"messages": [{"role": "system", "content": system},
|
| 150 |
{"role": "user", "content": user}]})
|
| 151 |
if r.status_code >= 400:
|
| 152 |
+
# β NO BODY ON A 4xx/5xx: an error envelope carries no usage, and a provider that refused
|
| 153 |
+
# before running the model has nothing to bill. The call is still COUNTED by `decide`.
|
| 154 |
+
return "", f"{p['name']} answered {r.status_code}", None
|
| 155 |
body = r.json()
|
| 156 |
choices = body.get("choices") or []
|
| 157 |
if not choices:
|
| 158 |
+
return "", f"{p['name']} returned no choices", body
|
| 159 |
+
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), "", body
|
| 160 |
|
| 161 |
|
| 162 |
def _call_anthropic(p, model, system, user, timeout):
|
|
|
|
| 167 |
json={"model": model, "max_tokens": 300, "system": system,
|
| 168 |
"messages": [{"role": "user", "content": user}]})
|
| 169 |
if r.status_code >= 400:
|
| 170 |
+
return "", f"anthropic answered {r.status_code}", None
|
| 171 |
body = r.json()
|
| 172 |
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
|
| 173 |
# reading content[0] before this check turns a refusal into an IndexError inside a run.
|
| 174 |
+
# β A refusal IS billed and its body carries `usage`, so the body rides back on this branch too.
|
| 175 |
if body.get("stop_reason") == "refusal":
|
| 176 |
+
return "", "anthropic declined to answer this record", body
|
| 177 |
parts = [b.get("text") or "" for b in (body.get("content") or [])
|
| 178 |
if isinstance(b, dict) and b.get("type") == "text"]
|
| 179 |
if not parts:
|
| 180 |
+
return "", "anthropic returned no text", body
|
| 181 |
+
return "".join(parts), "", body
|
| 182 |
|
| 183 |
|
| 184 |
+
def decide(*, prompt, options, row, fields=(), label="Review", timeout=None, st=None, user=""):
|
| 185 |
"""Pick this record's next stage. Returns `(choice, meta)`.
|
| 186 |
|
| 187 |
`choice` is "" whenever a person should decide β which is every failure mode there is.
|
| 188 |
`meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
|
| 189 |
+
|
| 190 |
+
β W35-T41 / C7 β `st` and `user` are the USAGE LEDGER's target. Both default to absent because
|
| 191 |
+
this function's caller is `automation_engine.ai_decide(rt, ...)`, in another lane's fence: it
|
| 192 |
+
HAS the runtime and does not pass it yet, so until it does, a review's tokens are counted as
|
| 193 |
+
unattributed and REPORTED by `GET /usage` rather than dropped. See `usage_ledger`'s header for
|
| 194 |
+
why they cannot be resolved implicitly (measured: a contextvar does not survive a FastAPI
|
| 195 |
+
dependency).
|
| 196 |
"""
|
| 197 |
+
import usage_ledger # noqa: PLC0415
|
| 198 |
opts = [str(o) for o in (options or []) if str(o).strip()]
|
| 199 |
if not opts:
|
| 200 |
return "", {"problem": "the review offers no next stages"}
|
|
|
|
| 210 |
problems = []
|
| 211 |
for p in live:
|
| 212 |
model = override or p["model"]
|
| 213 |
+
body = None
|
| 214 |
try:
|
| 215 |
+
text, err, body = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
|
| 216 |
p, model, system, user, tmo)
|
| 217 |
except Exception as e: # noqa: BLE001
|
| 218 |
text, err = "", f"{p['name']} failed: {type(e).__name__}"
|
| 219 |
+
# ββ C7 β THE LEDGER LINE, BEFORE ANY BRANCH BELOW READS THE ANSWER.
|
| 220 |
+
# β IT IS WRITTEN ON EVERY OUTCOME THAT REACHED A PROVIDER, including a refusal and an
|
| 221 |
+
# unusable answer. A meter that books only successes reports a cheap week for a run that
|
| 222 |
+
# spent its budget being declined β and a declined call is billed. The only path that does
|
| 223 |
+
# NOT record is a transport that never reached the vendor (`body is None`), which spent
|
| 224 |
+
# nothing.
|
| 225 |
+
if body is not None:
|
| 226 |
+
ins, outs = usage_ledger.tokens_from(body)
|
| 227 |
+
usage_ledger.record("ai_review", p["name"], model, ins, outs,
|
| 228 |
+
total=usage_ledger.total_from(body), st=st, user=user)
|
| 229 |
if err:
|
| 230 |
problems.append(err)
|
| 231 |
continue # ladder: a dead provider degrades to the next one
|
|
|
|
| 420 |
"actions": out}, ""
|
| 421 |
|
| 422 |
|
| 423 |
+
def draft_flow(*, prompt, catalog, required, triggers, tables, chat=None, timeout=None,
|
| 424 |
+
st=None, user=""):
|
| 425 |
"""A sentence -> `(draft, refusal_sentence, provider)`. β NOTHING IS SAVED HERE.
|
| 426 |
|
| 427 |
Exactly one of `draft` and `refusal_sentence` is truthy β the same contract
|
| 428 |
`web_agent.run_step` keeps, so a caller has no third case to get wrong.
|
| 429 |
+
|
| 430 |
+
β W35-T41 / C7 β `st`/`user` are the usage ledger's target, exactly as on `decide` above and for
|
| 431 |
+
the same reason: both of this function's callers (`automation_engine._ai_agent_plan` and
|
| 432 |
+
`routes_automation`'s draft door) are in lane D's fence. C7 says E adds the ledger line here and
|
| 433 |
+
D asserts it; the two keywords are what D has to pass for the line to be attributable.
|
| 434 |
"""
|
| 435 |
+
import usage_ledger # noqa: PLC0415
|
| 436 |
text = str(prompt or "").strip()[:MAX_PROMPT_CHARS]
|
| 437 |
if not text:
|
| 438 |
return None, "type what you want the automation to do", None
|
|
|
|
| 484 |
problems.append(f"{p['name']}: HTTP {r.status_code}")
|
| 485 |
continue
|
| 486 |
try:
|
| 487 |
+
body = r.json()
|
| 488 |
+
calls = (((body.get("choices") or [{}])[0].get("message") or {})
|
| 489 |
.get("tool_calls") or [])
|
| 490 |
args = json.loads(calls[0]["function"]["arguments"]) if calls else None
|
| 491 |
except Exception as e: # noqa: BLE001
|
| 492 |
problems.append(f"{p['name']}: unreadable answer ({type(e).__name__})")
|
| 493 |
continue
|
| 494 |
+
# ββ C7 β THE LEDGER LINE. Placed after the parse rather than before it so an UNREADABLE
|
| 495 |
+
# answer is not double-counted by the `continue` above... β which means an unreadable answer
|
| 496 |
+
# is NOT counted at all, and that is a deliberate, disclosed loss: a body this code cannot
|
| 497 |
+
# parse is a body whose `usage` it also cannot trust, and `body` is out of scope in that
|
| 498 |
+
# branch by construction. The 200-with-junk case is rare and named here rather than
|
| 499 |
+
# silently rounded to zero.
|
| 500 |
+
ins, outs = usage_ledger.tokens_from(body)
|
| 501 |
+
usage_ledger.record("automation_draft", p["name"], p["model"], ins, outs,
|
| 502 |
+
total=usage_ledger.total_from(body), st=st, user=user)
|
| 503 |
draft, refusal = _flow_from_tool_call(args)
|
| 504 |
if draft is None and isinstance(args, dict) and str(args.get("kind")) == "refused":
|
| 505 |
# β A REFUSAL IS AN ANSWER, NOT A FAULT, so it does NOT fall through to a more
|
api/automation_engine.py
CHANGED
|
@@ -1782,12 +1782,15 @@ def clean_schedule(raw, previous=None):
|
|
| 1782 |
# β `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
|
| 1783 |
# that predates this field must not start moving records on its own the day the code ships. A
|
| 1784 |
# loop is a thing somebody turns on.
|
| 1785 |
-
def clean_flow(raw, previous=None, notes=None):
|
| 1786 |
-
"""Validate the builder's ordered action list. `(flow, error)`.
|
|
|
|
|
|
|
|
|
|
| 1787 |
raw = raw if isinstance(raw, dict) else {}
|
| 1788 |
prev = previous if isinstance(previous, dict) else {}
|
| 1789 |
actions, err = clean_actions(
|
| 1790 |
-
raw.get("actions") if "actions" in raw else prev.get("actions"), notes=notes)
|
| 1791 |
if err:
|
| 1792 |
return None, err
|
| 1793 |
return {"actions": actions}, None
|
|
@@ -2062,7 +2065,7 @@ def ig_action_pinned(defn, index):
|
|
| 2062 |
return (defn or {}).get("kind") in DISCOVERY_KINDS and index == 0
|
| 2063 |
|
| 2064 |
|
| 2065 |
-
def clean_definition(raw, previous=None, username="", notes=None):
|
| 2066 |
"""Whole-definition validation. Returns `(defn, error)`."""
|
| 2067 |
raw = raw if isinstance(raw, dict) else {}
|
| 2068 |
prev = previous or {}
|
|
@@ -2239,7 +2242,7 @@ def clean_definition(raw, previous=None, username="", notes=None):
|
|
| 2239 |
_prev_table, _prev_enrich = discovery_seed_spec(prev.get("kind"))
|
| 2240 |
flow_raw = _drop_ig_seeds(
|
| 2241 |
flow_raw, (prev.get("config") or {}).get("targetTable") or _prev_table, _prev_enrich)
|
| 2242 |
-
flow, ferr = clean_flow(flow_raw, prev.get("flow"), notes=notes)
|
| 2243 |
if ferr:
|
| 2244 |
return None, ferr
|
| 2245 |
# β WAVE 30 β widened with the seeder above: TikTok now HAS a pinned step 1, so the rule that
|
|
@@ -2315,6 +2318,22 @@ def clean_definition(raw, previous=None, username="", notes=None):
|
|
| 2315 |
"state": dict(prev.get("state") or {}),
|
| 2316 |
"created": prev.get("created") or _iso(),
|
| 2317 |
"createdBy": prev.get("createdBy") or username,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2318 |
}, None
|
| 2319 |
|
| 2320 |
|
|
@@ -2616,7 +2635,9 @@ def create(rt, raw, username="", notes=None):
|
|
| 2616 |
# has, instead of letting the pure validator mint a second empty one. See
|
| 2617 |
# `discover_default_table`; a target the caller named is never rewritten.
|
| 2618 |
raw = _apply_discover_default(rt, raw)
|
| 2619 |
-
|
|
|
|
|
|
|
| 2620 |
if err:
|
| 2621 |
_unmint(rt, minted)
|
| 2622 |
return None, err
|
|
@@ -2671,7 +2692,7 @@ def patch(rt, auto_id, raw, username="", notes=None):
|
|
| 2671 |
# config that has never carried a target through the pure validator, which mints the empty
|
| 2672 |
# `ut_ig_candidates`. `create` alone would have left the commonest path untouched.
|
| 2673 |
merged = _apply_discover_default(rt, merged)
|
| 2674 |
-
defn, err = clean_definition(merged, prev, username, notes=notes)
|
| 2675 |
if err:
|
| 2676 |
return None, err
|
| 2677 |
defn["id"] = str(auto_id)
|
|
@@ -2987,6 +3008,21 @@ def remove(rt, auto_id):
|
|
| 2987 |
# that belongs to the WRITE and cannot be removed from here. What is gone is the expensive one.
|
| 2988 |
# β AND THE FALLBACK IS DELIBERATE: `all_defs` degrades to the whole read on any failure, so a
|
| 2989 |
# store that cannot project still deletes correctly, only slower.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2990 |
try:
|
| 2991 |
import core.user_tables as _ut_defs # noqa: PLC0415
|
| 2992 |
_scan = dict(_ut_defs.all_defs(rt) or {})
|
|
@@ -7864,6 +7900,288 @@ def _flow_cap_reason(table_key, rt=None):
|
|
| 7864 |
return f". {cause}." + (f" To walk them all: {fix}." if fix else "")
|
| 7865 |
|
| 7866 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7867 |
def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=None):
|
| 7868 |
"""β WAVE 24 / R6 β the runner for an automation with NO machine step: its flow IS the whole
|
| 7869 |
automation. Returns the same 5-tuple every other runner does, so `run_now` needs no special
|
|
@@ -7878,6 +8196,14 @@ def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=No
|
|
| 7878 |
exact defect this module names in three other places. It reports the honest state and says
|
| 7879 |
which fact is missing.
|
| 7880 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7881 |
table = _flow_table(defn)
|
| 7882 |
if not table:
|
| 7883 |
return ("partial",
|
|
@@ -8617,7 +8943,7 @@ AI_AGENT_MAX_STEPS = 12
|
|
| 8617 |
_AI_AGENT_CHAT = [None]
|
| 8618 |
|
| 8619 |
|
| 8620 |
-
def _ai_agent_plan(cfg, row, log=print):
|
| 8621 |
"""A description + one record -> `(steps, "")` for `run_plan`, or `(None, sentence)`.
|
| 8622 |
|
| 8623 |
ββ W33-T56. This is the whole of the fuzzy step: the instruction and the record's own values
|
|
@@ -8646,6 +8972,10 @@ def _ai_agent_plan(cfg, row, log=print):
|
|
| 8646 |
catalog=web_only,
|
| 8647 |
required={k: v for k, v in ACTION_REQUIRED.items() if k in WEB_KINDS},
|
| 8648 |
triggers=[], tables=[],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8649 |
chat=_AI_AGENT_CHAT[0])
|
| 8650 |
if why or not draft:
|
| 8651 |
return None, (why or "the assistant produced no steps for that instruction")
|
|
@@ -8865,6 +9195,30 @@ ACTION_CATALOG = [
|
|
| 8865 |
"history"},
|
| 8866 |
{"kind": "send_email", "label": "Send email", "group": "Connected", "ready": False,
|
| 8867 |
"detail": "Needs a send scope on the Gmail connection"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8868 |
{"kind": "slack", "label": "Send Slack message", "group": "Connected", "ready": False,
|
| 8869 |
"detail": "Needs the Slack connector"},
|
| 8870 |
# ββ 4. ADVANCED LOGIC ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -8905,10 +9259,63 @@ def _connector_meta(key):
|
|
| 8905 |
return next((dict(v) for v in TRIGGER_CONNECTOR.values() if v.get("key") == key), None)
|
| 8906 |
|
| 8907 |
|
| 8908 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8909 |
"""The catalog as the wire carries it β a copy, because a caller that mutated the module
|
| 8910 |
constant would change every later reader's answer.
|
| 8911 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8912 |
β WAVE 24 (C-ACT): each row is stamped with its `groupOrder`, DERIVED from
|
| 8913 |
`ACTION_GROUP_ORDER` rather than hand-written per row, so a group cannot be given two
|
| 8914 |
different orders by two rows that claim to be in it. A group nobody has ordered sorts LAST
|
|
@@ -8933,6 +9340,8 @@ def action_catalog():
|
|
| 8933 |
last = max(ACTION_GROUP_ORDER.values()) + 1
|
| 8934 |
out = []
|
| 8935 |
for a in ACTION_CATALOG:
|
|
|
|
|
|
|
| 8936 |
row = {**a, "groupOrder": ACTION_GROUP_ORDER.get(a.get("group"), last),
|
| 8937 |
"menu": bool(a.get("menu", True))}
|
| 8938 |
conn = _connector_meta(a.get("connector"))
|
|
@@ -9024,9 +9433,17 @@ def _action_label(act):
|
|
| 9024 |
return str(act.get("kind") or "Action")
|
| 9025 |
|
| 9026 |
|
| 9027 |
-
def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
|
| 9028 |
"""Validate `flow.actions`. Returns `(actions, error)` β refuses, never coerces.
|
| 9029 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9030 |
Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
|
| 9031 |
survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
|
| 9032 |
move the selection under the person editing it.
|
|
@@ -9064,6 +9481,15 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
|
|
| 9064 |
if not row["ready"]:
|
| 9065 |
return None, (f"{row['label']!r} is on the menu but not built yet. "
|
| 9066 |
f"{row['detail'][0].lower()}{row['detail'][1:]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9067 |
count[0] += 1
|
| 9068 |
if count[0] > MAX_ACTIONS:
|
| 9069 |
return None, f"an automation runs at most {MAX_ACTIONS} actions"
|
|
@@ -9105,7 +9531,7 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
|
|
| 9105 |
if cerr:
|
| 9106 |
return None, cerr
|
| 9107 |
cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
|
| 9108 |
-
cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count, notes=notes)
|
| 9109 |
if cerr:
|
| 9110 |
return None, cerr
|
| 9111 |
# β D-75 β THE ALLOWLIST'S OWN DROPS, DIFFED HERE RATHER THAN REPORTED BY EACH ARM. Every
|
|
@@ -9125,8 +9551,14 @@ def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None):
|
|
| 9125 |
return out, None
|
| 9126 |
|
| 9127 |
|
| 9128 |
-
def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
|
| 9129 |
-
"""One action's `config`, per kind. Returns `(config, error)`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9130 |
if kind == "group":
|
| 9131 |
# β WAVE 24 Β· C-FORK (owner ruling R8) β a group is a FORK now: `{branches: [...]}`,
|
| 9132 |
# each `{id, label, cond, actions}`. It was one condition with one action list, i.e. an
|
|
@@ -9164,7 +9596,7 @@ def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
|
|
| 9164 |
# D-75: threaded into the branch too β an unconfigured step inside an If is
|
| 9165 |
# exactly the one a person cannot see, which is `_walk_actions`' own argument.
|
| 9166 |
kids, kerr = clean_actions(br.get("actions"), depth + 1, seen, count,
|
| 9167 |
-
notes=notes)
|
| 9168 |
if kerr:
|
| 9169 |
return None, kerr
|
| 9170 |
if not kids:
|
|
@@ -9229,6 +9661,34 @@ def _clean_action_config(kind, cfg, depth, seen, count, notes=None):
|
|
| 9229 |
f"unique on it. It writes: " + ", ".join(sorted(clean_vals)))
|
| 9230 |
out["uniqueOn"] = unique
|
| 9231 |
return out, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9232 |
if kind in ENRICH_KINDS:
|
| 9233 |
# β WAVE 30 Β· T08 β BOTH networks share this branch. See `ENRICH_KINDS`: the selection, the
|
| 9234 |
# cooldown, the limit clamps and their reasons are network-agnostic, and a second copy for
|
|
@@ -10348,6 +10808,29 @@ def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print
|
|
| 10348 |
# the table would silently apply one action's uniqueness rule to the other's rows.
|
| 10349 |
creates.setdefault((target, str(cfg.get("uniqueOn") or "")), []).append(vals)
|
| 10350 |
counts["created"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10351 |
elif kind == "ai_agent":
|
| 10352 |
# ββ W33-T56 (owner item 7, ruling R3) β THE FUZZY STEP, AT RUN TIME.
|
| 10353 |
#
|
|
@@ -10378,7 +10861,9 @@ def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print
|
|
| 10378 |
f"browser jobs of about 10-30 seconds each.")
|
| 10379 |
counts["webBlocked"] += 1
|
| 10380 |
continue
|
| 10381 |
-
|
|
|
|
|
|
|
| 10382 |
if _why:
|
| 10383 |
# β NAMED, NEVER OPAQUE β the second half of the `done-when`. "The assistant
|
| 10384 |
# could not work out how to do that" with the reason attached is actionable;
|
|
@@ -11770,9 +12255,16 @@ def ai_decide(rt, defn, act, row, row_id="", log=print):
|
|
| 11770 |
(ut_get(rt, ((defn.get("config") or {}).get("targetTable")
|
| 11771 |
or (defn.get("trigger") or {}).get("table") or "")) or {}
|
| 11772 |
).get("fields") or []]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11773 |
choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
|
| 11774 |
row=row, fields=[f for f in fields if f],
|
| 11775 |
-
label=cfg.get("label") or "Review"
|
|
|
|
| 11776 |
if not choice:
|
| 11777 |
if meta.get("problem"):
|
| 11778 |
log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
|
|
|
|
| 1782 |
# β `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
|
| 1783 |
# that predates this field must not start moving records on its own the day the code ships. A
|
| 1784 |
# loop is a thing somebody turns on.
|
| 1785 |
+
def clean_flow(raw, previous=None, notes=None, rt=None):
|
| 1786 |
+
"""Validate the builder's ordered action list. `(flow, error)`.
|
| 1787 |
+
|
| 1788 |
+
β `rt` is the tenant wall for gated action kinds (W35-T35 / C8) and is simply forwarded.
|
| 1789 |
+
"""
|
| 1790 |
raw = raw if isinstance(raw, dict) else {}
|
| 1791 |
prev = previous if isinstance(previous, dict) else {}
|
| 1792 |
actions, err = clean_actions(
|
| 1793 |
+
raw.get("actions") if "actions" in raw else prev.get("actions"), notes=notes, rt=rt)
|
| 1794 |
if err:
|
| 1795 |
return None, err
|
| 1796 |
return {"actions": actions}, None
|
|
|
|
| 2065 |
return (defn or {}).get("kind") in DISCOVERY_KINDS and index == 0
|
| 2066 |
|
| 2067 |
|
| 2068 |
+
def clean_definition(raw, previous=None, username="", notes=None, rt=None):
|
| 2069 |
"""Whole-definition validation. Returns `(defn, error)`."""
|
| 2070 |
raw = raw if isinstance(raw, dict) else {}
|
| 2071 |
prev = previous or {}
|
|
|
|
| 2242 |
_prev_table, _prev_enrich = discovery_seed_spec(prev.get("kind"))
|
| 2243 |
flow_raw = _drop_ig_seeds(
|
| 2244 |
flow_raw, (prev.get("config") or {}).get("targetTable") or _prev_table, _prev_enrich)
|
| 2245 |
+
flow, ferr = clean_flow(flow_raw, prev.get("flow"), notes=notes, rt=rt)
|
| 2246 |
if ferr:
|
| 2247 |
return None, ferr
|
| 2248 |
# β WAVE 30 β widened with the seeder above: TikTok now HAS a pinned step 1, so the rule that
|
|
|
|
| 2318 |
"state": dict(prev.get("state") or {}),
|
| 2319 |
"created": prev.get("created") or _iso(),
|
| 2320 |
"createdBy": prev.get("createdBy") or username,
|
| 2321 |
+
# ββ WAVE 35 Β· T37 β `system` IS STICKY ACROSS A PATCH, AND IT IS **NEVER READ FROM `raw`**.
|
| 2322 |
+
#
|
| 2323 |
+
# β THE BUG THIS CLOSES WAS ALREADY WRITTEN AND WOULD HAVE SHIPPED. `routes_automation.
|
| 2324 |
+
# delete_automation` refuses any stored row carrying `system`, which is what makes a seeded
|
| 2325 |
+
# system agent undeletable β and this function is a WHITELIST that did not carry the key, so
|
| 2326 |
+
# the FIRST EDIT of such an agent silently stripped its marker and made it deletable. The
|
| 2327 |
+
# seed would then mint it again on the next list call: a delete that appears to work and
|
| 2328 |
+
# undoes itself. Found by asserting the round trip rather than by reading the code.
|
| 2329 |
+
#
|
| 2330 |
+
# β FROM `prev` ONLY. Reading it from `raw` would let ANY caller POST
|
| 2331 |
+
# `{"system": "anything"}` and mint themselves an automation nobody can delete β a
|
| 2332 |
+
# privilege escalation through a field nobody validates. The flag is granted by the seeder
|
| 2333 |
+
# and inherited from the stored row, never asserted by a payload.
|
| 2334 |
+
# β Same shape and same reason as the pre-set FIELD flag, which DESIGN.md Β§4 already
|
| 2335 |
+
# describes as "sticky across a PATCH". One idea, one behaviour, two objects.
|
| 2336 |
+
**({"system": _s(prev.get("system"), 40)} if prev.get("system") else {}),
|
| 2337 |
}, None
|
| 2338 |
|
| 2339 |
|
|
|
|
| 2635 |
# has, instead of letting the pure validator mint a second empty one. See
|
| 2636 |
# `discover_default_table`; a target the caller named is never rewritten.
|
| 2637 |
raw = _apply_discover_default(rt, raw)
|
| 2638 |
+
# W35-T35 (C8): `rt=` is the tenant wall for gated action kinds. Both doors pass it; a door
|
| 2639 |
+
# that forgot would REFUSE the kind, not admit it (`TENANT_GATED_ACTIONS` is fail-closed).
|
| 2640 |
+
defn, err = clean_definition(raw, None, username, notes=notes, rt=rt)
|
| 2641 |
if err:
|
| 2642 |
_unmint(rt, minted)
|
| 2643 |
return None, err
|
|
|
|
| 2692 |
# config that has never carried a target through the pure validator, which mints the empty
|
| 2693 |
# `ut_ig_candidates`. `create` alone would have left the commonest path untouched.
|
| 2694 |
merged = _apply_discover_default(rt, merged)
|
| 2695 |
+
defn, err = clean_definition(merged, prev, username, notes=notes, rt=rt)
|
| 2696 |
if err:
|
| 2697 |
return None, err
|
| 2698 |
defn["id"] = str(auto_id)
|
|
|
|
| 3008 |
# that belongs to the WRITE and cannot be removed from here. What is gone is the expensive one.
|
| 3009 |
# β AND THE FALLBACK IS DELIBERATE: `all_defs` degrades to the whole read on any failure, so a
|
| 3010 |
# store that cannot project still deletes correctly, only slower.
|
| 3011 |
+
#
|
| 3012 |
+
# ββ WAVE 35 Β· T34 β THE RESIDUAL IS NOW MEASURED RATHER THAN ASSERTED, and the count is
|
| 3013 |
+
# better than the paragraph above claims. Whole `user_tables` reads per delete, against a
|
| 3014 |
+
# runtime that has production's `get_projection`:
|
| 3015 |
+
# TAGGED (a discovery flow that spawned columns) β 1 whole + 1 projected
|
| 3016 |
+
# UNTAGGED (a `plain` flow) β 0 whole + 1 projected
|
| 3017 |
+
# The untagged case pays NONE because `if not plan: return` fires before the write. The
|
| 3018 |
+
# tagged case's ONE is `rt.update`'s own strict read, and it is a FLOOR, not a choice:
|
| 3019 |
+
# `core/store.py::get_projection` states that a write must always read whole, because a
|
| 3020 |
+
# read-modify-write handed a rows-less document would upload the tenant with its rows deleted.
|
| 3021 |
+
# β Reaching ZERO needs D-179's `{flowId: [(table_key, field_key)]}` index, written where the
|
| 3022 |
+
# presets are spawned β a feature, not an edit to this function. Nothing here can do better.
|
| 3023 |
+
# β THOSE NUMBERS WERE UNOBSERVABLE UNTIL W35-T34: `verify_automation`'s delete-speed double
|
| 3024 |
+
# had no `get_projection`, so `all_defs` fell back and the gate measured 2 and 1 β the
|
| 3025 |
+
# PRE-WAVE-33 path β for two waves. The double is producer-faithful now and asserts the counts.
|
| 3026 |
try:
|
| 3027 |
import core.user_tables as _ut_defs # noqa: PLC0415
|
| 3028 |
_scan = dict(_ut_defs.all_defs(rt) or {})
|
|
|
|
| 7900 |
return f". {cause}." + (f" To walk them all: {fix}." if fix else "")
|
| 7901 |
|
| 7902 |
|
| 7903 |
+
# ---------------------------------------------------------------------------------------------
|
| 7904 |
+
# WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β THE STATEMENTS BATCH: ASSEMBLE, PARK, NEVER SEND
|
| 7905 |
+
#
|
| 7906 |
+
# ββ THE TICKET'S `how:` SAID TO USE "the board's strict review posture (wave 22)". THAT POSTURE
|
| 7907 |
+
# DOES NOT EXIST, and rebuilding it would REVERSE AN OWNER RULING rather than merely miss a symbol.
|
| 7908 |
+
# Wave 27 R3 deleted `board()`, `move_card()`, `stages_for()`, `ensure_stage_field()` and the review
|
| 7909 |
+
# branch of the action walk (the tombstone is above `LANE_OPS`), and its reason is a DATA reason,
|
| 7910 |
+
# verbatim: *"the board wrote MACHINE COLUMNS INTO A TENANT'S OWN TABLE β a stage select, an `_at`
|
| 7911 |
+
# stamp and a `_cycles` counter per automation β to render a view of state the RUN LOG already
|
| 7912 |
+
# holds."* `retire_automation_stage_fields` and `migrate_ig_tables` still DROP those columns, so a
|
| 7913 |
+
# batch parked in a stage field would be deleted by our own migration.
|
| 7914 |
+
# β The batch parks on the AUTOMATION DEFINITION, the shape `runs` and `reviews` already use. Zero
|
| 7915 |
+
# machine columns on a customer's database, and the migration has nothing to take away.
|
| 7916 |
+
#
|
| 7917 |
+
# β A STATEMENTS FLOW IS **BATCH**-SCOPED, NOT RECORD-SCOPED, and that is why it does not go
|
| 7918 |
+
# through `apply_actions` at all. `run_plain` walks every row of the flow's bound table, so a
|
| 7919 |
+
# per-record arm would assemble the whole worklist once per record; and a statements agent binds NO
|
| 7920 |
+
# table (its customers come from the Odoo AR worklist, not a `ut_*` grid), so `run_plain` would
|
| 7921 |
+
# have answered `partial: "no database is bound yet"` and the step would NEVER HAVE RUN. The
|
| 7922 |
+
# precedent for a kind that defines its own scope is already here: the enrich-only branch below,
|
| 7923 |
+
# whose saved View "ARE the set of records the automation was asked to process".
|
| 7924 |
+
# β The `_walk` arm for this kind therefore stays a REFUSAL, not a duplicate: a `send_statement`
|
| 7925 |
+
# dropped into an ordinary record-walking flow must say it did nothing, never half-send.
|
| 7926 |
+
#
|
| 7927 |
+
# ββ AND NOTHING HERE SENDS. Not one function in this section imports the mail path. The send door
|
| 7928 |
+
# is `routes_statements`, behind `admin_gate` + `_royal_only`, calling `collections_send.
|
| 7929 |
+
# queue_statement`, whose SAFE_MODE guardrail lives in the DATA LAYER where no route, payload or UI
|
| 7930 |
+
# can bypass it. This module ASSEMBLES and PARKS. A person clicks Send.
|
| 7931 |
+
|
| 7932 |
+
#: How many statements one parked batch holds. Bounded for the reason `MAX_RUNS` and `MAX_REVIEWS`
|
| 7933 |
+
#: are: this rides inside the automation DEFINITION, and an unbounded list is a serialisation cost
|
| 7934 |
+
#: on every read of the automations bucket.
|
| 7935 |
+
#: β WHEN IT BITES IT IS DISCLOSED, never a silent `[:N]` β R6's second sentence, and
|
| 7936 |
+
#: [[no-unverifiable-aggregates]]. `assemble_statements` appends a note naming the number left out.
|
| 7937 |
+
MAX_STATEMENT_BATCH = 200
|
| 7938 |
+
|
| 7939 |
+
#: The collection worklist loader, injectable so a gate can drive this without Odoo credentials.
|
| 7940 |
+
#: Production leaves it None and the real data layer answers. Same shape as `_DRAFT_CHAT`.
|
| 7941 |
+
_STATEMENT_ROWS = [None]
|
| 7942 |
+
|
| 7943 |
+
|
| 7944 |
+
def _collections():
|
| 7945 |
+
"""`modules.collections_send`, imported lazily β `platform/` is not on the path at import time
|
| 7946 |
+
for every consumer of this module, and this is the only section that needs it."""
|
| 7947 |
+
import modules.collections_send as cs # noqa: PLC0415
|
| 7948 |
+
return cs
|
| 7949 |
+
|
| 7950 |
+
|
| 7951 |
+
def statement_steps(defn):
|
| 7952 |
+
"""The ENABLED `send_statement` actions of this flow, top level only.
|
| 7953 |
+
|
| 7954 |
+
β Top level only, deliberately, and `is_statement_flow` is what makes that safe: a batch flow
|
| 7955 |
+
is exactly one enabled action. A `send_statement` nested inside an If is NOT a batch flow, so
|
| 7956 |
+
it falls to the ordinary record walk and is refused there with a sentence.
|
| 7957 |
+
"""
|
| 7958 |
+
return [a for a in (((defn or {}).get("flow") or {}).get("actions") or [])
|
| 7959 |
+
if isinstance(a, dict) and a.get("kind") == "send_statement"
|
| 7960 |
+
and a.get("enabled", True)]
|
| 7961 |
+
|
| 7962 |
+
|
| 7963 |
+
def is_statement_flow(defn):
|
| 7964 |
+
"""Is this automation a statements BATCH rather than a record walk?
|
| 7965 |
+
|
| 7966 |
+
β EXACTLY ONE ENABLED ACTION, AND IT IS THIS KIND. The narrowness is the safety: a flow that
|
| 7967 |
+
also updates records has record semantics that the batch path would silently drop, so it keeps
|
| 7968 |
+
the ordinary walk (where the arm refuses and says so). This mirrors the enrich-only branch in
|
| 7969 |
+
`run_plain`, which is narrow for the same stated reason.
|
| 7970 |
+
"""
|
| 7971 |
+
enabled = [a for a in (((defn or {}).get("flow") or {}).get("actions") or [])
|
| 7972 |
+
if isinstance(a, dict) and a.get("enabled", True)]
|
| 7973 |
+
return len(enabled) == 1 and enabled[0].get("kind") == "send_statement"
|
| 7974 |
+
|
| 7975 |
+
|
| 7976 |
+
def assemble_statements(defn, log=print):
|
| 7977 |
+
"""`(batch, notes)` β the statements this configuration WOULD send, rendered, plus why anybody
|
| 7978 |
+
was left out. **Reads Odoo read-only. Sends nothing. Writes nothing.**
|
| 7979 |
+
|
| 7980 |
+
Each entry is `{customer, to, subject, html, tier, overdue}`. `html` is rendered by the SAME
|
| 7981 |
+
`render_statement_html` the send door uses, so what a person approves is what goes out β a
|
| 7982 |
+
review screen rendering its own approximation of the mail is a review of the wrong thing.
|
| 7983 |
+
|
| 7984 |
+
β A CUSTOMER WITH NO EMAIL IS *SKIPPED AND NAMED*, never dropped. `routes_statements.send`
|
| 7985 |
+
already separates "we could not" from "there was nowhere to send"; this keeps that distinction
|
| 7986 |
+
at the assembly end, because a batch that silently shrinks is one nobody can reconcile.
|
| 7987 |
+
"""
|
| 7988 |
+
cfg = (statement_steps(defn) or [{}])[0].get("config") or {}
|
| 7989 |
+
cs = _collections()
|
| 7990 |
+
notes = []
|
| 7991 |
+
loader = _STATEMENT_ROWS[0]
|
| 7992 |
+
rows = list(loader() if loader else cs.load_collection_list(cs.Odoo()))
|
| 7993 |
+
tier = str(cfg.get("tier") or "").strip()
|
| 7994 |
+
if tier:
|
| 7995 |
+
before = len(rows)
|
| 7996 |
+
rows = [r for r in rows if str(r.get("Tier") or "") == tier]
|
| 7997 |
+
log(f"[aios-auto] statements: {len(rows)} of {before} customers are in tier {tier}")
|
| 7998 |
+
subject_tpl = str(cfg.get("subject") or "") or cs.DEFAULT_SUBJECT
|
| 7999 |
+
intro_tpl = str(cfg.get("intro") or "") or cs.DEFAULT_INTRO
|
| 8000 |
+
footer_tpl = str(cfg.get("footer") or "") or cs.DEFAULT_FOOTER
|
| 8001 |
+
month = _dt.date.today().strftime("%B %Y")
|
| 8002 |
+
batch, no_email = [], []
|
| 8003 |
+
for row in rows:
|
| 8004 |
+
if len(batch) >= MAX_STATEMENT_BATCH:
|
| 8005 |
+
break
|
| 8006 |
+
name = str(row.get("Customer") or "")
|
| 8007 |
+
to = str(row.get("Email") or "").strip()
|
| 8008 |
+
if not to:
|
| 8009 |
+
no_email.append(name)
|
| 8010 |
+
continue
|
| 8011 |
+
try:
|
| 8012 |
+
subject = subject_tpl.format(customer=name, company=cs.COMPANY, month=month)
|
| 8013 |
+
except (KeyError, IndexError):
|
| 8014 |
+
# An unknown placeholder is the person's typo, not a crash. Show what they typed β
|
| 8015 |
+
# the same choice `routes_statements.preview` already makes.
|
| 8016 |
+
subject = subject_tpl
|
| 8017 |
+
batch.append({"customer": name, "to": to, "subject": _s(subject, 200),
|
| 8018 |
+
"html": _s(cs.render_statement_html(row, intro_tpl, footer_tpl), 40000),
|
| 8019 |
+
"tier": str(row.get("Tier") or ""), "overdue": row.get("Overdue")})
|
| 8020 |
+
if no_email:
|
| 8021 |
+
shown = ", ".join(no_email[:5])
|
| 8022 |
+
one = len(no_email) == 1
|
| 8023 |
+
notes.append(f"{len(no_email)} customer{'' if one else 's'} "
|
| 8024 |
+
f"{'has' if one else 'have'} no email address on their record and "
|
| 8025 |
+
f"{'is' if one else 'are'} not in this batch "
|
| 8026 |
+
f"({shown}{', and others' if len(no_email) > 5 else ''}). "
|
| 8027 |
+
f"Add an address in Odoo and run it again")
|
| 8028 |
+
left = len(rows) - len(batch) - len(no_email)
|
| 8029 |
+
if left > 0:
|
| 8030 |
+
# R6's second sentence: a limit that bites is REPORTED, with its cause and what to do.
|
| 8031 |
+
notes.append(f"{left} more customers matched but one batch holds "
|
| 8032 |
+
f"{MAX_STATEMENT_BATCH}. Send this batch, then run it again for the rest, or "
|
| 8033 |
+
f"narrow the tier filter")
|
| 8034 |
+
return batch, notes
|
| 8035 |
+
|
| 8036 |
+
|
| 8037 |
+
def park_statements(rt, auto_id, batch, notes):
|
| 8038 |
+
"""Write the assembled batch onto the DEFINITION as `pendingStatements`. Replaces, never
|
| 8039 |
+
appends: a batch is THIS run's worklist, and two runs' statements merged into one list is a
|
| 8040 |
+
customer invoiced twice.
|
| 8041 |
+
|
| 8042 |
+
β `flush="async"` for the reason every writer in this module gives β the read-your-writes
|
| 8043 |
+
contract means the next `GET /automations` already sees it, and only the upload is deferred.
|
| 8044 |
+
"""
|
| 8045 |
+
entry = {"ts": _iso(), "count": len(batch), "sent": False,
|
| 8046 |
+
"items": list(batch)[:MAX_STATEMENT_BATCH],
|
| 8047 |
+
"notes": [_s(n, 300) for n in (notes or [])][:25]}
|
| 8048 |
+
|
| 8049 |
+
def _up(cur):
|
| 8050 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 8051 |
+
d = cur.get(str(auto_id))
|
| 8052 |
+
if d is not None:
|
| 8053 |
+
d["pendingStatements"] = entry
|
| 8054 |
+
return cur
|
| 8055 |
+
|
| 8056 |
+
_store_update(rt, _up, flush="async")
|
| 8057 |
+
return entry
|
| 8058 |
+
|
| 8059 |
+
|
| 8060 |
+
def clear_statements(rt, auto_id):
|
| 8061 |
+
"""Drop a parked batch β after it is sent, or when somebody discards it."""
|
| 8062 |
+
def _up(cur):
|
| 8063 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 8064 |
+
d = cur.get(str(auto_id))
|
| 8065 |
+
if d is not None:
|
| 8066 |
+
d.pop("pendingStatements", None)
|
| 8067 |
+
return cur
|
| 8068 |
+
|
| 8069 |
+
_store_update(rt, _up, flush="async")
|
| 8070 |
+
|
| 8071 |
+
|
| 8072 |
+
def run_statements(rt, defn, username="automation", log=print, step=_no_step):
|
| 8073 |
+
"""The batch runner: assemble, park, tell somebody. Returns `run_plain`'s 5-tuple.
|
| 8074 |
+
|
| 8075 |
+
β THE STATE IS `partial`, NEVER `ok`, AND THAT IS DELIBERATE. `ok` would put a green dot over
|
| 8076 |
+
a job that is only half done β the statements exist and NOBODY HAS SENT THEM. `partial` is this
|
| 8077 |
+
module's honest-progress state and the summary says exactly what is waiting, which is what makes
|
| 8078 |
+
the run notification (`_notify_run`) read as "come and look" rather than "done".
|
| 8079 |
+
"""
|
| 8080 |
+
if not is_statement_tenant(rt):
|
| 8081 |
+
# Belt and braces behind `clean_actions`' wall: a definition stored before the wall, or
|
| 8082 |
+
# copied between tenants, must not assemble another company's customer list.
|
| 8083 |
+
return ("error", "statements are not configured for this workspace", {}, [], {})
|
| 8084 |
+
step("reading the collection list")
|
| 8085 |
+
try:
|
| 8086 |
+
batch, notes = assemble_statements(defn, log=log)
|
| 8087 |
+
except Exception as exc: # noqa: BLE001
|
| 8088 |
+
return ("error", f"the collection list could not be read: {str(exc)[:200]}", {}, [], {})
|
| 8089 |
+
step("rendering statements")
|
| 8090 |
+
park_statements(rt, defn.get("id"), batch, notes)
|
| 8091 |
+
counts = {"statements": len(batch)}
|
| 8092 |
+
if not batch:
|
| 8093 |
+
return ("partial", "no customers matched, so there is nothing to review", counts, [], {})
|
| 8094 |
+
one = len(batch) == 1
|
| 8095 |
+
return ("partial",
|
| 8096 |
+
f"{len(batch)} statement{'' if one else 's'} "
|
| 8097 |
+
f"{'is' if one else 'are'} ready for review. Nothing has been sent. Open this agent "
|
| 8098 |
+
f"and click Send to release them",
|
| 8099 |
+
counts, [], {})
|
| 8100 |
+
|
| 8101 |
+
|
| 8102 |
+
#: ββ WAVE 35 Β· T37 β THE SEEDED ROYAL IMPORTS STATEMENTS AGENT.
|
| 8103 |
+
#: A FIXED id, because the row's own EXISTENCE is the idempotency key (see `seed_statements_agent`).
|
| 8104 |
+
STATEMENTS_AGENT_ID = "system:statements"
|
| 8105 |
+
SYSTEM_STATEMENTS = "statements"
|
| 8106 |
+
#: First of the month, 06:00. A monthly statement run is what the collections worklist is for, and
|
| 8107 |
+
#: the hour matches the Odoo sync default so the numbers it reads are the night's.
|
| 8108 |
+
STATEMENTS_DEFAULT_CRON = "0 6 1 * *"
|
| 8109 |
+
|
| 8110 |
+
|
| 8111 |
+
def seed_statements_agent(rt, username="system"):
|
| 8112 |
+
"""Mint Royal Imports' "Monthly statements" agent ONCE, **switched OFF**. Returns the
|
| 8113 |
+
definition if it wrote one, else None.
|
| 8114 |
+
|
| 8115 |
+
ββ IT ARRIVES `enabled: False` AND THAT IS THE TICKET'S OWN TRAP, not a preference. An agent
|
| 8116 |
+
that ships enabled has scheduled itself against real customers before a single person has read
|
| 8117 |
+
its configuration β and this one's action queues mail to the tenant's actual debtors.
|
| 8118 |
+
|
| 8119 |
+
β WHY STORED, WHEN WAVE 34's SYSTEM AGENT IS DERIVED. `_odoo_sync_row` gives three reasons for
|
| 8120 |
+
deriving and only one survives contact with THIS agent (raised as `ASK D-11`):
|
| 8121 |
+
Β· "two copies of one cadence" β does not apply: Odoo's cadence already lives in the connector
|
| 8122 |
+
config, so a stored copy could drift; a statements schedule has no other home to drift from.
|
| 8123 |
+
Β· "stored means the failure-pause can disable it" β INVERTS: auto-pausing statements after K
|
| 8124 |
+
consecutive failures is correct (a dead credential should stop it), where pausing the Odoo
|
| 8125 |
+
cadence would silently break infrastructure.
|
| 8126 |
+
Β· "nothing can seed it" β real, and answered here: ONE write per tenant ever, by the
|
| 8127 |
+
CONTAINER (which is what D-195 asks for; what it forbids is a CLI writing while the Space
|
| 8128 |
+
is up), behind the existence check below.
|
| 8129 |
+
And deriving cannot meet T37 anyway: a derived row is not in the automations bucket, so edit,
|
| 8130 |
+
toggle, schedule and run would each need a bespoke door β four new mechanisms to avoid one
|
| 8131 |
+
guarded write. The Odoo agent escapes that only because a separate resync loop already does its
|
| 8132 |
+
work.
|
| 8133 |
+
|
| 8134 |
+
β THE EXISTENCE CHECK IS THE WHOLE IDEMPOTENCY STORY, AND IT IS SAFE ONLY BECAUSE THE ROW
|
| 8135 |
+
CANNOT BE DELETED. `routes_automation.delete_automation` refuses any stored row carrying
|
| 8136 |
+
`system` (409, with a sentence), so "the row is present" can never become false behind our
|
| 8137 |
+
back. If that refusal is ever relaxed, THIS FUNCTION NEEDS A SEPARATE DURABLE FLAG β otherwise
|
| 8138 |
+
deleting the agent resurrects it on the next list call, which is a delete that undoes itself.
|
| 8139 |
+
β And `system` is STICKY through `clean_definition` (see its return) for the same reason: an
|
| 8140 |
+
edit that stripped the marker would make the row deletable and re-open exactly that hole.
|
| 8141 |
+
"""
|
| 8142 |
+
if not is_statement_tenant(rt):
|
| 8143 |
+
return None
|
| 8144 |
+
existing = all_definitions(rt) or {}
|
| 8145 |
+
if STATEMENTS_AGENT_ID in existing:
|
| 8146 |
+
return None
|
| 8147 |
+
raw = {
|
| 8148 |
+
"name": "Monthly statements",
|
| 8149 |
+
"kind": "plain",
|
| 8150 |
+
# β A SCHEDULE THAT IS OFF, not an absent schedule: the cron is the CONFIGURATION T37 asks
|
| 8151 |
+
# a person to open and read, and a blank one would make them invent it before they could
|
| 8152 |
+
# judge it.
|
| 8153 |
+
"schedule": {"cron": STATEMENTS_DEFAULT_CRON, "enabled": False},
|
| 8154 |
+
"trigger": {"key": "schedule"},
|
| 8155 |
+
"flow": {"actions": [{
|
| 8156 |
+
"id": "act_1", "kind": "send_statement",
|
| 8157 |
+
# β TIER BLANK = every tier, which is the honest default: narrowing to "A-Urgent" would
|
| 8158 |
+
# be us deciding who gets chased, and the blank is the value the config panel shows as
|
| 8159 |
+
# "all of them" rather than an empty box.
|
| 8160 |
+
"config": {"tier": "", "subject": "", "intro": "", "footer": ""},
|
| 8161 |
+
}]},
|
| 8162 |
+
}
|
| 8163 |
+
defn, err = clean_definition(raw, None, username, rt=rt)
|
| 8164 |
+
if err:
|
| 8165 |
+
return None
|
| 8166 |
+
defn["id"] = STATEMENTS_AGENT_ID
|
| 8167 |
+
# Stamped AFTER the clean, because `clean_definition` reads this key from `prev` only β a
|
| 8168 |
+
# payload may never assert it (see that function's note).
|
| 8169 |
+
defn["system"] = SYSTEM_STATEMENTS
|
| 8170 |
+
|
| 8171 |
+
def _up(cur):
|
| 8172 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 8173 |
+
# β RE-CHECKED INSIDE THE MUTATION. Two concurrent list calls can both pass the read above;
|
| 8174 |
+
# the store applies mutations under its own lock, so this is where "only once" is actually
|
| 8175 |
+
# decided. Without it the second writer would overwrite a row a person may already have
|
| 8176 |
+
# edited, silently resetting their schedule and template.
|
| 8177 |
+
if STATEMENTS_AGENT_ID not in cur:
|
| 8178 |
+
cur[STATEMENTS_AGENT_ID] = defn
|
| 8179 |
+
return cur
|
| 8180 |
+
|
| 8181 |
+
_store_update(rt, _up, flush="async")
|
| 8182 |
+
return defn
|
| 8183 |
+
|
| 8184 |
+
|
| 8185 |
def run_plain(rt, defn, username="automation", log=print, step=_no_step, rows=None):
|
| 8186 |
"""β WAVE 24 / R6 β the runner for an automation with NO machine step: its flow IS the whole
|
| 8187 |
automation. Returns the same 5-tuple every other runner does, so `run_now` needs no special
|
|
|
|
| 8196 |
exact defect this module names in three other places. It reports the honest state and says
|
| 8197 |
which fact is missing.
|
| 8198 |
"""
|
| 8199 |
+
# ββ WAVE 35 Β· T36 β THE BATCH FLOW BRANCHES BEFORE THE TABLE CHECK, and the ORDER is the
|
| 8200 |
+
# whole of it. A statements agent binds no `ut_*` table (its customers come from the Odoo AR
|
| 8201 |
+
# worklist), so leaving this below the `if not table` return would answer "no database is bound
|
| 8202 |
+
# yet" and the step would never run β a feature that is whole, gated and unreachable, which is
|
| 8203 |
+
# this repo's most-repeated failure. See the section above `run_plain` for why it is not an
|
| 8204 |
+
# `apply_actions` arm.
|
| 8205 |
+
if is_statement_flow(defn):
|
| 8206 |
+
return run_statements(rt, defn, username=username, log=log, step=step)
|
| 8207 |
table = _flow_table(defn)
|
| 8208 |
if not table:
|
| 8209 |
return ("partial",
|
|
|
|
| 8943 |
_AI_AGENT_CHAT = [None]
|
| 8944 |
|
| 8945 |
|
| 8946 |
+
def _ai_agent_plan(cfg, row, log=print, st=None, user=""):
|
| 8947 |
"""A description + one record -> `(steps, "")` for `run_plan`, or `(None, sentence)`.
|
| 8948 |
|
| 8949 |
ββ W33-T56. This is the whole of the fuzzy step: the instruction and the record's own values
|
|
|
|
| 8972 |
catalog=web_only,
|
| 8973 |
required={k: v for k, v in ACTION_REQUIRED.items() if k in WEB_KINDS},
|
| 8974 |
triggers=[], tables=[],
|
| 8975 |
+
# β W35 Β· C7 (`NOTE E-16`) β threaded IN from `_walk` rather than resolved here: this
|
| 8976 |
+
# function is pure over `(cfg, row)` by design and giving it a store handle of its own
|
| 8977 |
+
# would be a second way to reach the tenant.
|
| 8978 |
+
st=st, user=user,
|
| 8979 |
chat=_AI_AGENT_CHAT[0])
|
| 8980 |
if why or not draft:
|
| 8981 |
return None, (why or "the assistant produced no steps for that instruction")
|
|
|
|
| 9195 |
"history"},
|
| 9196 |
{"kind": "send_email", "label": "Send email", "group": "Connected", "ready": False,
|
| 9197 |
"detail": "Needs a send scope on the Gmail connection"},
|
| 9198 |
+
# ββ WAVE 35 Β· T35 / CONTRACT C8 / OWNER RULING R10 β STATEMENTS BECOME AN AGENT STEP.
|
| 9199 |
+
#
|
| 9200 |
+
# Owner item 14: move Statements out of Settings and into the agent automation, "templatic and
|
| 9201 |
+
# easily toggleable". R10 is the half that decides the shape: the step ASSEMBLES and PARKS a
|
| 9202 |
+
# batch in a review stage, and **nothing sends without a human click**. So this row's promise
|
| 9203 |
+
# is deliberately "prepares", not "sends" β the label a person reads must not describe an act
|
| 9204 |
+
# the step does not perform.
|
| 9205 |
+
#
|
| 9206 |
+
# β ADDED BESIDE `send_email`, NEVER BY REPURPOSING IT (the ticket's own trap, D-295): a
|
| 9207 |
+
# stored automation naming a kind that no longer exists is refused FOREVER rather than dropped
|
| 9208 |
+
# with a reason, so adding a kind is cheap and re-pointing one is not.
|
| 9209 |
+
#
|
| 9210 |
+
# β `ready: True` IS WHAT MAKES IT STORABLE β `clean_actions` refuses any row whose `ready` is
|
| 9211 |
+
# false β and the note at `enrich_tiktok` above is the reason the RUNNER ARM lands in the same
|
| 9212 |
+
# wave rather than after it: `_walk` has no terminal `else`, so a catalog row ahead of its arm
|
| 9213 |
+
# is addable, storable and SILENTLY INERT, which is worse than not existing. T35 lands an arm
|
| 9214 |
+
# that reports it is not configured; T36 makes it park a real batch.
|
| 9215 |
+
#
|
| 9216 |
+
# β TENANT-GATED, not `ready: False`: `TENANT_GATED_ACTIONS` withholds this row from every
|
| 9217 |
+
# tenant but #0, because the send client behind it is env-credentialed and is tenant #0's.
|
| 9218 |
+
{"kind": "send_statement", "label": "Prepare customer statements", "group": "Connected",
|
| 9219 |
+
"ready": True,
|
| 9220 |
+
"detail": "Assemble this month's statements and park them for review. Nothing is sent until "
|
| 9221 |
+
"somebody opens the batch and clicks Send"},
|
| 9222 |
{"kind": "slack", "label": "Send Slack message", "group": "Connected", "ready": False,
|
| 9223 |
"detail": "Needs the Slack connector"},
|
| 9224 |
# ββ 4. ADVANCED LOGIC ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 9259 |
return next((dict(v) for v in TRIGGER_CONNECTOR.values() if v.get("key") == key), None)
|
| 9260 |
|
| 9261 |
|
| 9262 |
+
#: ββ WAVE 35 Β· T35 / CONTRACT C8 β THE TENANT PREDICATE `routes_statements._royal_only` USES,
|
| 9263 |
+
#: LIFTED SO THERE IS EXACTLY ONE COPY OF IT. C8's words are "the predicate is imported, never
|
| 9264 |
+
#: re-expressed", and this is the direction that import can run: `automation_engine` imports no
|
| 9265 |
+
#: route module and no FastAPI (checked), and breaking that to reach `_royal_only` would drag
|
| 9266 |
+
#: `deps` + `routes_admin` into the engine. So the ENGINE holds the test and the ROUTE calls it.
|
| 9267 |
+
#:
|
| 9268 |
+
#: ββ THIS IS NOT `odoo_relational.is_royal`, AND THE DIFFERENCE IS A SEND. That one asks "is this
|
| 9269 |
+
#: tenant ENTITLED to Odoo databases" over `RI_SLUGS = ("", "royal-imports")` β it answers TRUE for
|
| 9270 |
+
#: the EMPTY slug and lower-cases its input. This one is `_royal_only`'s exact test: the runtime's
|
| 9271 |
+
#: own `key`, matched exactly. A tenant whose key never got set would pass `is_royal("")` and then
|
| 9272 |
+
#: queue statements THROUGH TENANT #0'S ENV-CREDENTIALED SEND CLIENT, i.e. email another company's
|
| 9273 |
+
#: customers over Royal Imports' name. Entitlement to READ is not authority to SEND, and the two
|
| 9274 |
+
#: questions keep their two predicates on purpose. Do not "unify" them.
|
| 9275 |
+
ROYAL_TENANT_KEY = "royal-imports"
|
| 9276 |
+
|
| 9277 |
+
|
| 9278 |
+
def is_statement_tenant(rt):
|
| 9279 |
+
"""Exactly `routes_statements._royal_only`'s test, as a boolean over the RUNTIME.
|
| 9280 |
+
|
| 9281 |
+
Keyed on the runtime, never on a request field: a tenant is a property of the SESSION, so a
|
| 9282 |
+
payload cannot argue its way into another company's sender.
|
| 9283 |
+
"""
|
| 9284 |
+
return getattr(rt, "key", None) == ROYAL_TENANT_KEY
|
| 9285 |
+
|
| 9286 |
+
|
| 9287 |
+
#: Kinds only SOME tenants may see or store, as `{kind: predicate(rt) -> bool}`.
|
| 9288 |
+
#:
|
| 9289 |
+
#: β FAIL-CLOSED ON `rt=None`, AND THAT IS THE WHOLE SAFETY ARGUMENT. Every reader below treats an
|
| 9290 |
+
#: absent runtime as "not allowed", so a call site that forgets to pass one makes the action VANISH
|
| 9291 |
+
#: rather than become universal. The opposite default would mean any future caller of
|
| 9292 |
+
#: `action_catalog()` or `clean_actions()` silently offers tenant #0's send door to every tenant β
|
| 9293 |
+
#: an omission that is invisible in review and loud only in production [[default-must-pass-its-own-guard]].
|
| 9294 |
+
TENANT_GATED_ACTIONS = {"send_statement": is_statement_tenant}
|
| 9295 |
+
|
| 9296 |
+
#: The dunning buckets a statements step may filter on. β ONE LITERAL, TWO READERS: this and
|
| 9297 |
+
#: `routes_statements.statements()`'s `"tiers"` field are the same four strings, and a step
|
| 9298 |
+
#: configured against a tier the sender's worklist does not produce would filter to nothing and
|
| 9299 |
+
#: report success. `routes_statements` imports this rather than repeating it.
|
| 9300 |
+
STATEMENT_TIERS = ("A-Urgent", "B-Active", "C-Light", "Monitor")
|
| 9301 |
+
|
| 9302 |
+
|
| 9303 |
+
def _tenant_may_use(kind, rt):
|
| 9304 |
+
"""May this runtime see/store this action kind? Ungated kinds are always yes."""
|
| 9305 |
+
gate = TENANT_GATED_ACTIONS.get(str(kind or ""))
|
| 9306 |
+
return True if gate is None else bool(rt is not None and gate(rt))
|
| 9307 |
+
|
| 9308 |
+
|
| 9309 |
+
def action_catalog(rt=None):
|
| 9310 |
"""The catalog as the wire carries it β a copy, because a caller that mutated the module
|
| 9311 |
constant would change every later reader's answer.
|
| 9312 |
|
| 9313 |
+
ββ WAVE 35 Β· T35 (C8): `rt` filters TENANT-GATED rows out entirely β not `ready: False`, not
|
| 9314 |
+
`menu: False`, but ABSENT. A tenant that may not send statements should not learn that the
|
| 9315 |
+
capability exists, and `ready: False` renders as "coming soon", which is a promise we are not
|
| 9316 |
+
making to them. β Called with no `rt` the gated rows are withheld (fail-closed): see
|
| 9317 |
+
`TENANT_GATED_ACTIONS`.
|
| 9318 |
+
|
| 9319 |
β WAVE 24 (C-ACT): each row is stamped with its `groupOrder`, DERIVED from
|
| 9320 |
`ACTION_GROUP_ORDER` rather than hand-written per row, so a group cannot be given two
|
| 9321 |
different orders by two rows that claim to be in it. A group nobody has ordered sorts LAST
|
|
|
|
| 9340 |
last = max(ACTION_GROUP_ORDER.values()) + 1
|
| 9341 |
out = []
|
| 9342 |
for a in ACTION_CATALOG:
|
| 9343 |
+
if not _tenant_may_use(a.get("kind"), rt):
|
| 9344 |
+
continue
|
| 9345 |
row = {**a, "groupOrder": ACTION_GROUP_ORDER.get(a.get("group"), last),
|
| 9346 |
"menu": bool(a.get("menu", True))}
|
| 9347 |
conn = _connector_meta(a.get("connector"))
|
|
|
|
| 9433 |
return str(act.get("kind") or "Action")
|
| 9434 |
|
| 9435 |
|
| 9436 |
+
def clean_actions(raw, depth=0, _seen=None, _count=None, notes=None, rt=None):
|
| 9437 |
"""Validate `flow.actions`. Returns `(actions, error)` β refuses, never coerces.
|
| 9438 |
|
| 9439 |
+
ββ WAVE 35 Β· T35 (C8) β `rt` IS THE TENANT WALL ON THE STORE SIDE, and it is a separate wall
|
| 9440 |
+
from `action_catalog(rt)`'s. Withholding a row from the MENU stops it being offered; it does
|
| 9441 |
+
not stop a hand-written body naming the kind, and the picker is not a security boundary
|
| 9442 |
+
[[opening-a-route-widens-every-field]]. β Keyword-only in effect and defaulting to None, so
|
| 9443 |
+
every existing caller is untouched by construction β the same shape `notes` used, for the
|
| 9444 |
+
reason this docstring already gives about a signature change failing at RUN, not at import.
|
| 9445 |
+
β `rt=None` REFUSES a gated kind rather than allowing it (`TENANT_GATED_ACTIONS`).
|
| 9446 |
+
|
| 9447 |
Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
|
| 9448 |
survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
|
| 9449 |
move the selection under the person editing it.
|
|
|
|
| 9481 |
if not row["ready"]:
|
| 9482 |
return None, (f"{row['label']!r} is on the menu but not built yet. "
|
| 9483 |
f"{row['detail'][0].lower()}{row['detail'][1:]}")
|
| 9484 |
+
# ββ W35-T35 (C8) β THE TENANT WALL. Refused, not dropped, and this is the one place in
|
| 9485 |
+
# this function where a refusal is right: D-65's "drop, never refuse" protects a stored
|
| 9486 |
+
# automation from becoming permanently unsavable, and NO tenant this can refuse has ever
|
| 9487 |
+
# been able to store one β the kind is withheld from their catalog, so there is no legacy
|
| 9488 |
+
# body to strand. Dropping it silently would instead let a flow save, look saved, and never
|
| 9489 |
+
# do the step the person configured.
|
| 9490 |
+
if not _tenant_may_use(kind, rt):
|
| 9491 |
+
return None, (f"{row['label']!r} is not available in this workspace. It sends as "
|
| 9492 |
+
f"Royal Imports, using Royal Imports' own mail credentials")
|
| 9493 |
count[0] += 1
|
| 9494 |
if count[0] > MAX_ACTIONS:
|
| 9495 |
return None, f"an automation runs at most {MAX_ACTIONS} actions"
|
|
|
|
| 9531 |
if cerr:
|
| 9532 |
return None, cerr
|
| 9533 |
cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
|
| 9534 |
+
cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count, notes=notes, rt=rt)
|
| 9535 |
if cerr:
|
| 9536 |
return None, cerr
|
| 9537 |
# β D-75 β THE ALLOWLIST'S OWN DROPS, DIFFED HERE RATHER THAN REPORTED BY EACH ARM. Every
|
|
|
|
| 9551 |
return out, None
|
| 9552 |
|
| 9553 |
|
| 9554 |
+
def _clean_action_config(kind, cfg, depth, seen, count, notes=None, rt=None):
|
| 9555 |
+
"""One action's `config`, per kind. Returns `(config, error)`.
|
| 9556 |
+
|
| 9557 |
+
β `rt` is threaded ONLY so the `group` arm can hand it back to `clean_actions` for the branch
|
| 9558 |
+
recursion (W35-T35 / C8). Without it a tenant-gated kind is refused at the top level and
|
| 9559 |
+
ACCEPTED inside an If, which is the half of a flow `_walk_actions`' own docstring says people
|
| 9560 |
+
cannot see.
|
| 9561 |
+
"""
|
| 9562 |
if kind == "group":
|
| 9563 |
# β WAVE 24 Β· C-FORK (owner ruling R8) β a group is a FORK now: `{branches: [...]}`,
|
| 9564 |
# each `{id, label, cond, actions}`. It was one condition with one action list, i.e. an
|
|
|
|
| 9596 |
# D-75: threaded into the branch too β an unconfigured step inside an If is
|
| 9597 |
# exactly the one a person cannot see, which is `_walk_actions`' own argument.
|
| 9598 |
kids, kerr = clean_actions(br.get("actions"), depth + 1, seen, count,
|
| 9599 |
+
notes=notes, rt=rt)
|
| 9600 |
if kerr:
|
| 9601 |
return None, kerr
|
| 9602 |
if not kids:
|
|
|
|
| 9661 |
f"unique on it. It writes: " + ", ".join(sorted(clean_vals)))
|
| 9662 |
out["uniqueOn"] = unique
|
| 9663 |
return out, None
|
| 9664 |
+
if kind == "send_statement":
|
| 9665 |
+
# ββ WAVE 35 Β· T35 / R10 β WHAT A STATEMENTS STEP IS CONFIGURED WITH: a customer filter
|
| 9666 |
+
# and a template. Both are the SENDER's own vocabulary (`routes_statements.send` passes
|
| 9667 |
+
# `templates: {subject, intro, footer}` straight to `collections_send.queue_statement`),
|
| 9668 |
+
# so this arm names no field the send door does not already take.
|
| 9669 |
+
#
|
| 9670 |
+
# β EVERY TEMPLATE FIELD IS OPTIONAL AND EMPTY MEANS "THE DEFAULT", which is exactly how
|
| 9671 |
+
# the send door already reads them (`t.get("subject") or cs.DEFAULT_SUBJECT`). Storing our
|
| 9672 |
+
# own copy of the default instead would freeze today's wording into every stored agent and
|
| 9673 |
+
# silently stop tracking `collections_send`'s.
|
| 9674 |
+
out = {}
|
| 9675 |
+
tier = _s(cfg.get("tier"), 40).strip()
|
| 9676 |
+
# β REFUSED, NOT COERCED, AND THE ANSWER LISTS THE REAL ONES β the same shape the connector
|
| 9677 |
+
# cadence uses. A tier nobody sends to would filter the batch to zero customers and report
|
| 9678 |
+
# a successful run: a silent no-op is the worst outcome available here, because the person
|
| 9679 |
+
# believes their customers were invoiced.
|
| 9680 |
+
# β `""` IS LEGAL and means EVERY tier. It is the stored default, so an agent saved before
|
| 9681 |
+
# anybody picks a filter does not change meaning the day this key arrives (D-65's rule).
|
| 9682 |
+
if tier and tier not in STATEMENT_TIERS:
|
| 9683 |
+
return None, (f"{tier!r} is not a collection tier. Pick one of: "
|
| 9684 |
+
+ ", ".join(STATEMENT_TIERS) + ", or leave it blank for all of them")
|
| 9685 |
+
out["tier"] = tier
|
| 9686 |
+
for key, cap in (("subject", 200), ("intro", 4000), ("footer", 4000)):
|
| 9687 |
+
out[key] = _s(cfg.get(key), cap)
|
| 9688 |
+
lbl = " ".join(_s(cfg.get("label"), 60).split())
|
| 9689 |
+
if lbl:
|
| 9690 |
+
out["label"] = lbl
|
| 9691 |
+
return out, None
|
| 9692 |
if kind in ENRICH_KINDS:
|
| 9693 |
# β WAVE 30 Β· T08 β BOTH networks share this branch. See `ENRICH_KINDS`: the selection, the
|
| 9694 |
# cooldown, the limit clamps and their reasons are network-agnostic, and a second copy for
|
|
|
|
| 10808 |
# the table would silently apply one action's uniqueness rule to the other's rows.
|
| 10809 |
creates.setdefault((target, str(cfg.get("uniqueOn") or "")), []).append(vals)
|
| 10810 |
counts["created"] += 1
|
| 10811 |
+
elif kind == "send_statement":
|
| 10812 |
+
# ββ WAVE 35 Β· T35 / R10 β THE ARM LANDS WITH THE CATALOG ROW, ON PURPOSE.
|
| 10813 |
+
#
|
| 10814 |
+
# `_walk` has no terminal `else` (see the note on `enrich_tiktok` in the catalog):
|
| 10815 |
+
# an unknown kind is walked, COUNTED, reports the run `ok` and writes nothing. So a
|
| 10816 |
+
# catalog row whose arm arrives in a later ticket is addable, storable and silently
|
| 10817 |
+
# inert β a step a person configured, that reports success and does nothing. This
|
| 10818 |
+
# arm exists so that window never opens.
|
| 10819 |
+
#
|
| 10820 |
+
# β T35 DOES NOT SEND AND DOES NOT PARK. R10's review stage is W35-T36; until it
|
| 10821 |
+
# lands this says so out loud and counts the record as blocked, which is the same
|
| 10822 |
+
# shape `ai_agent` uses for a step it cannot perform. It must never fall through to
|
| 10823 |
+
# "ok".
|
| 10824 |
+
# β AND IT NEVER SENDS FROM HERE, in this wave or any later one. The send door is
|
| 10825 |
+
# `routes_statements`, behind SAFE_MODE + `admin_gate` + the tenant gate, reached by
|
| 10826 |
+
# a human click on the review batch. This arm's whole job is to PREPARE.
|
| 10827 |
+
if "send_statement_pending" not in web_notes:
|
| 10828 |
+
web_notes.append("send_statement_pending")
|
| 10829 |
+
log("[aios-auto] send_statement: statements are assembled for review, not "
|
| 10830 |
+
"sent. The review batch is not configured yet, so nothing was prepared "
|
| 10831 |
+
"and nothing was sent.")
|
| 10832 |
+
counts["webBlocked"] += 1
|
| 10833 |
+
continue
|
| 10834 |
elif kind == "ai_agent":
|
| 10835 |
# ββ W33-T56 (owner item 7, ruling R3) β THE FUZZY STEP, AT RUN TIME.
|
| 10836 |
#
|
|
|
|
| 10861 |
f"browser jobs of about 10-30 seconds each.")
|
| 10862 |
counts["webBlocked"] += 1
|
| 10863 |
continue
|
| 10864 |
+
# W35 Β· C7: `st` + `user` so the model spend is attributed (`NOTE E-16`).
|
| 10865 |
+
_plan, _why = _ai_agent_plan(cfg, row, log, st=rt,
|
| 10866 |
+
user=str(defn.get("createdBy") or ""))
|
| 10867 |
if _why:
|
| 10868 |
# β NAMED, NEVER OPAQUE β the second half of the `done-when`. "The assistant
|
| 10869 |
# could not work out how to do that" with the reason attached is actionable;
|
|
|
|
| 12255 |
(ut_get(rt, ((defn.get("config") or {}).get("targetTable")
|
| 12256 |
or (defn.get("trigger") or {}).get("table") or "")) or {}
|
| 12257 |
).get("fields") or []]
|
| 12258 |
+
# β W35 Β· CONTRACT C7 (`NOTE E-16`) β ATTRIBUTE THE SPEND. `st=` is what lets the ledger write
|
| 12259 |
+
# to the right tenant's store and `user=` is who it is billed to; without them the call is
|
| 12260 |
+
# counted as UNATTRIBUTED, which is a meter that reports a total nobody can act on.
|
| 12261 |
+
# β `createdBy` is the honest actor here: an AI review decision is made ON BEHALF of the
|
| 12262 |
+
# automation, by a scheduler, with no person at the keyboard. Naming whoever last edited it
|
| 12263 |
+
# would attribute a nightly run to an editor who was asleep.
|
| 12264 |
choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
|
| 12265 |
row=row, fields=[f for f in fields if f],
|
| 12266 |
+
label=cfg.get("label") or "Review",
|
| 12267 |
+
st=rt, user=str(defn.get("createdBy") or ""))
|
| 12268 |
if not choice:
|
| 12269 |
if meta.get("problem"):
|
| 12270 |
log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
|
api/main.py
CHANGED
|
@@ -82,6 +82,9 @@ import routes_query # noqa: E402 (wave 32 R1/C5 β the Query module; E's rout
|
|
| 82 |
import routes_publish # noqa: E402 (wave 33 R5/C2 β the publish door; C's router, A's line)
|
| 83 |
import routes_brand # noqa: E402 (wave 33 R4/C2/C6 β connector brand marks; G's router, A's line)
|
| 84 |
import routes_slack # noqa: E402 (wave 33 R4/C2 β Manage agent + the Slack door; D's router, A's line)
|
|
|
|
|
|
|
|
|
|
| 85 |
from core import grid_events # noqa: E402
|
| 86 |
from deps import Session, module_gate # noqa: E402
|
| 87 |
|
|
@@ -310,6 +313,22 @@ app.include_router(routes_brand.router) # R4 / C2 / C6 β G's router, A's
|
|
| 310 |
# file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why
|
| 311 |
# `verify_api` asserts the absence rather than an entry in a list that does not exist.
|
| 312 |
app.include_router(routes_slack.router) # R4 / C2 β D's router, A's line (Manage agent + Slack)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
|
| 315 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
|
@@ -472,6 +491,12 @@ def _seed_and_sync_store():
|
|
| 472 |
# is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
|
| 473 |
# refuses β correctly, but for a reason that reads like a data loss scare.
|
| 474 |
# β Same thread on purpose: it is already a daemon and nothing serves requests behind it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
_pull_meta("boot")
|
| 476 |
_rebuild_odoo_relational("boot")
|
| 477 |
# ββ W32-T07 β owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
|
|
@@ -483,6 +508,35 @@ def _seed_and_sync_store():
|
|
| 483 |
print(f"[aios-api] datastore seed/sync skipped: {e}")
|
| 484 |
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
def _pull_meta(why):
|
| 487 |
"""Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.
|
| 488 |
|
|
|
|
| 82 |
import routes_publish # noqa: E402 (wave 33 R5/C2 β the publish door; C's router, A's line)
|
| 83 |
import routes_brand # noqa: E402 (wave 33 R4/C2/C6 β connector brand marks; G's router, A's line)
|
| 84 |
import routes_slack # noqa: E402 (wave 33 R4/C2 β Manage agent + the Slack door; D's router, A's line)
|
| 85 |
+
import routes_starred # noqa: E402 (wave 35 R4/C2/C3/C5 β the star; E's router, E's line)
|
| 86 |
+
import routes_usage # noqa: E402 (wave 35 R9/C7 β the AI usage meter; E's router, E's line)
|
| 87 |
+
import routes_feedback # noqa: E402 (wave 35 R8/C6 β feedback to the operator plane; E's router)
|
| 88 |
from core import grid_events # noqa: E402
|
| 89 |
from deps import Session, module_gate # noqa: E402
|
| 90 |
|
|
|
|
| 313 |
# file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why
|
| 314 |
# `verify_api` asserts the absence rather than an entry in a list that does not exist.
|
| 315 |
app.include_router(routes_slack.router) # R4 / C2 β D's router, A's line (Manage agent + Slack)
|
| 316 |
+
# ββ WAVE 35 (R4/R5/R7, contracts C2/C3/C5) β THE STAR. Mounted in the SAME change that created
|
| 317 |
+
# `routes_starred.py`, which is the seventh consecutive wave in which this block is the artefact the
|
| 318 |
+
# protocol nearly loses β and the first in which the router's own lane also owns `main.py`, so there
|
| 319 |
+
# is no ask/mount pair to drop. `W35-T46` asserts every one of this wave's paths in
|
| 320 |
+
# `app.openapi()["paths"]` and CALLS one route from each.
|
| 321 |
+
# β Same placement rule as every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)`, or
|
| 322 |
+
# the router answers 404 on GET and 405 on POST while every one of its own tests passes.
|
| 323 |
+
app.include_router(routes_starred.router) # R4 / C2 β the star, counts, and record stars
|
| 324 |
+
# β R9 SAYS THIS ROUTE REPORTS AND NEVER ENFORCES, so mounting it cannot cut anybody off β there is
|
| 325 |
+
# no ceiling anywhere behind it (`usage_ledger` has no refusal in it). The one AI limit the product
|
| 326 |
+
# enforces is per COLUMN and is unchanged (`ai_enrich.ceiling_report`).
|
| 327 |
+
app.include_router(routes_usage.router) # R9 / C7 β GET /usage, the one AI meter
|
| 328 |
+
# β ITS TWO DOORS CARRY DIFFERENT WALLS ON PURPOSE (R8, and D-221 is the booked precedent): the
|
| 329 |
+
# POST is any authenticated session's own act, the GET is `is_platform_admin` only. Mounting it does
|
| 330 |
+
# not widen anything a tenant admin can reach β `verify_api` proves that by having one try.
|
| 331 |
+
app.include_router(routes_feedback.router) # R8 / C6 β feedback to the operator plane
|
| 332 |
|
| 333 |
|
| 334 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
|
|
|
| 491 |
# is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
|
| 492 |
# refuses β correctly, but for a reason that reads like a data loss scare.
|
| 493 |
# β Same thread on purpose: it is already a daemon and nothing serves requests behind it.
|
| 494 |
+
# ββ W35-T45 / R11 β TENANT #0'S ODOO CREDENTIAL MOVES ONTO ITS KEYCHAIN, IN THE CONTAINER.
|
| 495 |
+
# β BEFORE the relational rebuild, deliberately: the rebuild resolves its connector through
|
| 496 |
+
# `rt.odoo_source()`, so running the migration first means the very next read already goes
|
| 497 |
+
# through the keychain branch and a broken migration is visible in THIS boot's log rather
|
| 498 |
+
# than in tomorrow's. It is idempotent, so every later boot is one `list_entries` read.
|
| 499 |
+
_migrate_env_odoo("boot")
|
| 500 |
_pull_meta("boot")
|
| 501 |
_rebuild_odoo_relational("boot")
|
| 502 |
# ββ W32-T07 β owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
|
|
|
|
| 508 |
print(f"[aios-api] datastore seed/sync skipped: {e}")
|
| 509 |
|
| 510 |
|
| 511 |
+
def _migrate_env_odoo(why):
|
| 512 |
+
"""ββ W35-T45 / R11 β move tenant #0's environment Odoo credential onto its keychain.
|
| 513 |
+
|
| 514 |
+
ββ IN THE CONTAINER, WHICH IS THE WHOLE REASON THIS IS A BOOT LINE AND NOT A SCRIPT. D-195,
|
| 515 |
+
measured three times: a developer's CLI write to the tenant store is reverted by the running
|
| 516 |
+
Space within a minute (download-modify-upload, last-write-wins) β and the write REPORTS SUCCESS
|
| 517 |
+
every time, then a fresh read confirms it, and it is gone by the next poll. A CLI migration would
|
| 518 |
+
be a dry run that lies, and what it would lie about here is a credential.
|
| 519 |
+
|
| 520 |
+
β IDEMPOTENT AND SCOPED TO TENANT #0 by `routes_keychain.env_odoo_available`, so on every other
|
| 521 |
+
tenant and on every later boot this is one `list_entries` read and a line.
|
| 522 |
+
β FAIL-QUIET: this must never take a boot down. But it is never SILENT β a skip prints its reason,
|
| 523 |
+
because "already migrated", "no keychain key on this deployment" and "the env is incomplete" are
|
| 524 |
+
three different operator actions and a blank Keychain page cannot tell them apart.
|
| 525 |
+
"""
|
| 526 |
+
try:
|
| 527 |
+
import routes_keychain as _kc_routes
|
| 528 |
+
from harness import runtime as _runtime
|
| 529 |
+
rep = _kc_routes.migrate_env_odoo(_runtime.get_runtime("royal-imports"))
|
| 530 |
+
if rep.get("done"):
|
| 531 |
+
print(f"[aios-api] odoo credential migrated onto the keychain ({why}): "
|
| 532 |
+
f"entry={rep['entry']} carried_pause={rep['carried_pause']}"
|
| 533 |
+
+ (f" PROBLEM: {rep['why']}" if rep.get("why") else ""))
|
| 534 |
+
else:
|
| 535 |
+
print(f"[aios-api] odoo keychain migration skipped ({why}): {rep.get('why')}")
|
| 536 |
+
except Exception as e: # noqa: BLE001
|
| 537 |
+
print(f"[aios-api] odoo keychain migration FAILED ({why}): {type(e).__name__}: {e}")
|
| 538 |
+
|
| 539 |
+
|
| 540 |
def _pull_meta(why):
|
| 541 |
"""Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.
|
| 542 |
|
api/routes_automation.py
CHANGED
|
@@ -91,6 +91,27 @@ def _wire(defn, tenant):
|
|
| 91 |
# C3 (wave 22): the trigger config rides whole β the webhook token included, because
|
| 92 |
# the person configuring the external caller has to be shown the URL somewhere, and
|
| 93 |
# this payload is session-gated behind the same wall as everything else here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
"trigger": defn.get("trigger") or None,
|
| 95 |
"statusNote": defn.get("statusNote") or "",
|
| 96 |
# The one-sentence summary (airtable-brief rec 7), composed from the definition so it
|
|
@@ -550,6 +571,13 @@ def _tick_state():
|
|
| 550 |
#: or delete β a new database cannot contain the legacy stage cells this retires.
|
| 551 |
_BOARD_RETIRED = set()
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
#: ββ WAVE 30 Β· T12 β THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
|
| 554 |
#: `scope_cache` stores.
|
| 555 |
#:
|
|
@@ -702,6 +730,20 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 702 |
# Idempotent Board retirement removes only engine-marked stage fields and legacy Board
|
| 703 |
# state. User-created Status/Stage columns remain intact.
|
| 704 |
engine.retire_automation_board_state(session.runtime, tables=tables)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
defs = engine.all_definitions(session.runtime)
|
| 706 |
items = [_wire(d, session.tenant) for _, d in
|
| 707 |
sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
|
|
@@ -801,7 +843,11 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 801 |
# own reason instead of omitting them β the owner asked for Airtable's full menu, and
|
| 802 |
# a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
|
| 803 |
# unready kind, so the faded state is a wall rather than a styling choice.
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
# The builder's own vocabulary: how deep a condition tree may nest, how deep groups
|
| 806 |
# may nest, and the ceilings. B reads these instead of hard-coding the same numbers
|
| 807 |
# into its "+ Add condition" affordance.
|
|
@@ -1109,7 +1155,10 @@ def draft_automation(body: dict = Body(default=None), session: Session = Depends
|
|
| 1109 |
# filtering one and not the other describes settings for kinds the model cannot choose. That is
|
| 1110 |
# not merely untidy: it spends prompt on unreachable options and invites the model to reach for
|
| 1111 |
# one. `_ai_agent_plan` already narrows both in exactly this way; this follows it.
|
| 1112 |
-
|
|
|
|
|
|
|
|
|
|
| 1113 |
_offered = {r["kind"] for r in menu_catalog}
|
| 1114 |
draft, refusal, provider = ai_review.draft_flow(
|
| 1115 |
prompt=prompt,
|
|
@@ -1117,14 +1166,21 @@ def draft_automation(body: dict = Body(default=None), session: Session = Depends
|
|
| 1117 |
required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered},
|
| 1118 |
triggers=_triggers_vocab(session),
|
| 1119 |
tables=tables,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
chat=_DRAFT_CHAT[0])
|
| 1121 |
if refusal or not draft:
|
| 1122 |
raise err(400, "draft_refused", refusal or "no automation could be drafted from that")
|
| 1123 |
|
| 1124 |
# ββ C7: run the real save-door validator and DIFF it ββββββββββββββββββββββββββββββββββ
|
| 1125 |
wrote = draft.get("actions") or []
|
|
|
|
|
|
|
|
|
|
| 1126 |
cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}}
|
| 1127 |
-
for a in wrote])
|
| 1128 |
if why or cleaned is None:
|
| 1129 |
raise err(400, "draft_invalid",
|
| 1130 |
f"the assistant produced a flow this deployment will not accept: {why}")
|
|
|
|
| 91 |
# C3 (wave 22): the trigger config rides whole β the webhook token included, because
|
| 92 |
# the person configuring the external caller has to be shown the URL somewhere, and
|
| 93 |
# this payload is session-gated behind the same wall as everything else here.
|
| 94 |
+
# ββ WAVE 35 Β· T36 / R10 β THE PARKED BATCH, SUMMARISED. Without this line the whole
|
| 95 |
+
# ticket would be unreachable: `_wire` is a WHITELIST, so a key the engine parks on the
|
| 96 |
+
# definition simply never arrives, and the detail pane could not know a batch was waiting
|
| 97 |
+
# ([[reachable-is-not-the-same-as-built]] β the exact shape `flow` was caught in above).
|
| 98 |
+
# β COUNT AND NOTES ONLY, NEVER `items`. A 200-statement batch carries 200 rendered HTML
|
| 99 |
+
# mails; this payload rides on the automations LIST, which every rail paint reads.
|
| 100 |
+
# `GET /admin/statements/agent/{id}` serves the detail when somebody opens the batch.
|
| 101 |
+
"pendingStatements": ({
|
| 102 |
+
"count": int((defn.get("pendingStatements") or {}).get("count") or 0),
|
| 103 |
+
"ts": (defn.get("pendingStatements") or {}).get("ts") or "",
|
| 104 |
+
"notes": list((defn.get("pendingStatements") or {}).get("notes") or [])[:25],
|
| 105 |
+
} if isinstance(defn.get("pendingStatements"), dict) else None),
|
| 106 |
+
# ββ WAVE 35 Β· T37 β `system` FOR A **STORED** ROW. Until now only SYNTHETIC rows carried
|
| 107 |
+
# it, stamped by their builders AFTER this function (`_odoo_sync_row`, `_field_agent_rows`),
|
| 108 |
+
# so a stored row with the marker had it enforced on the server (`delete_automation` 409s)
|
| 109 |
+
# and INVISIBLE to the client β which paints a live-looking Delete that answers with a
|
| 110 |
+
# refusal. `AutomationDetail` already reads `automation.system` to disable that button and
|
| 111 |
+
# explain why; this is the line that lets it.
|
| 112 |
+
# β Absent stays absent rather than becoming `""`: every ordinary automation is not-a-system
|
| 113 |
+
# -agent, and an empty string is a value a client could accidentally treat as one.
|
| 114 |
+
**({"system": str(defn.get("system") or "")} if defn.get("system") else {}),
|
| 115 |
"trigger": defn.get("trigger") or None,
|
| 116 |
"statusNote": defn.get("statusNote") or "",
|
| 117 |
# The one-sentence summary (airtable-brief rec 7), composed from the definition so it
|
|
|
|
| 571 |
#: or delete β a new database cannot contain the legacy stage cells this retires.
|
| 572 |
_BOARD_RETIRED = set()
|
| 573 |
|
| 574 |
+
#: β WAVE 35 Β· T37 β tenants whose statements agent has been considered THIS PROCESS. Same shape as
|
| 575 |
+
#: `_BOARD_RETIRED` and, like it, purely a cost saver: the DURABLE idempotency is the row's own
|
| 576 |
+
#: existence, which `engine.seed_statements_agent` re-checks inside its own store mutation.
|
| 577 |
+
#: β SO A RESTART RE-CHECKING IS HARMLESS BY CONSTRUCTION, which is the property to preserve. If
|
| 578 |
+
#: this set were ever the only guard, a process restart would mint a second agent.
|
| 579 |
+
_STATEMENTS_SEEDED = set()
|
| 580 |
+
|
| 581 |
#: ββ WAVE 30 Β· T12 β THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
|
| 582 |
#: `scope_cache` stores.
|
| 583 |
#:
|
|
|
|
| 730 |
# Idempotent Board retirement removes only engine-marked stage fields and legacy Board
|
| 731 |
# state. User-created Status/Stage columns remain intact.
|
| 732 |
engine.retire_automation_board_state(session.runtime, tables=tables)
|
| 733 |
+
# ββ WAVE 35 Β· T37 / OWNER RULING R10 β ROYAL IMPORTS' STATEMENTS AGENT EXISTS BEFORE ANYBODY
|
| 734 |
+
# ASKS FOR IT. It is minted ONCE, by the container, SWITCHED OFF; every subsequent call finds
|
| 735 |
+
# it present and returns immediately. See `engine.seed_statements_agent` for why this one is
|
| 736 |
+
# STORED where wave 34's Odoo agent is derived, and why the row's existence is a sufficient
|
| 737 |
+
# idempotency key (it cannot be deleted).
|
| 738 |
+
# β GUARDED SO IT CAN NEVER TAKE THE RAIL DOWN. A tenant with no Odoo, a store mid-outage or a
|
| 739 |
+
# validator change must degrade to "no statements agent", never to a 500 on the one route the
|
| 740 |
+
# whole Agents surface polls. The same posture `_odoo_sync_row` takes for the same reason.
|
| 741 |
+
if session.tenant not in _STATEMENTS_SEEDED:
|
| 742 |
+
try:
|
| 743 |
+
engine.seed_statements_agent(session.runtime, username=session.uname or "system")
|
| 744 |
+
except Exception: # noqa: BLE001
|
| 745 |
+
pass
|
| 746 |
+
_STATEMENTS_SEEDED.add(session.tenant)
|
| 747 |
defs = engine.all_definitions(session.runtime)
|
| 748 |
items = [_wire(d, session.tenant) for _, d in
|
| 749 |
sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
|
|
|
|
| 843 |
# own reason instead of omitting them β the owner asked for Airtable's full menu, and
|
| 844 |
# a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
|
| 845 |
# unready kind, so the faded state is a wall rather than a styling choice.
|
| 846 |
+
# β W35-T35 (C8): `session.runtime` is what withholds a TENANT-GATED row. Passing it
|
| 847 |
+
# here is the whole of "the menu does not offer Send statements to another tenant";
|
| 848 |
+
# the STORE-side wall is `clean_actions(rt=)`, deliberately separate, because a picker
|
| 849 |
+
# is not a security boundary [[opening-a-route-widens-every-field]].
|
| 850 |
+
"actionsCatalog": engine.action_catalog(session.runtime),
|
| 851 |
# The builder's own vocabulary: how deep a condition tree may nest, how deep groups
|
| 852 |
# may nest, and the ceilings. B reads these instead of hard-coding the same numbers
|
| 853 |
# into its "+ Add condition" affordance.
|
|
|
|
| 1155 |
# filtering one and not the other describes settings for kinds the model cannot choose. That is
|
| 1156 |
# not merely untidy: it spends prompt on unreachable options and invites the model to reach for
|
| 1157 |
# one. `_ai_agent_plan` already narrows both in exactly this way; this follows it.
|
| 1158 |
+
# W35-T35 (C8): tenant-gated rows are withheld here too, or the DRAFTER would offer a kind
|
| 1159 |
+
# the same tenant's own save door then refuses β a flow the assistant proposes and the product
|
| 1160 |
+
# will not accept.
|
| 1161 |
+
menu_catalog = [r for r in engine.action_catalog(session.runtime) if r.get("menu") is not False]
|
| 1162 |
_offered = {r["kind"] for r in menu_catalog}
|
| 1163 |
draft, refusal, provider = ai_review.draft_flow(
|
| 1164 |
prompt=prompt,
|
|
|
|
| 1166 |
required={k: v for k, v in engine.ACTION_REQUIRED.items() if k in _offered},
|
| 1167 |
triggers=_triggers_vocab(session),
|
| 1168 |
tables=tables,
|
| 1169 |
+
# β W35 Β· CONTRACT C7 (`NOTE E-16`) β the spend is ATTRIBUTED. Unlike the two engine call
|
| 1170 |
+
# sites, this one has a real person behind it: somebody typed the sentence, so `user` is
|
| 1171 |
+
# the caller rather than the automation's owner.
|
| 1172 |
+
st=session.runtime, user=getattr(session, "uname", "") or "",
|
| 1173 |
chat=_DRAFT_CHAT[0])
|
| 1174 |
if refusal or not draft:
|
| 1175 |
raise err(400, "draft_refused", refusal or "no automation could be drafted from that")
|
| 1176 |
|
| 1177 |
# ββ C7: run the real save-door validator and DIFF it ββββββββββββββββββββββββββββββββββ
|
| 1178 |
wrote = draft.get("actions") or []
|
| 1179 |
+
# W35-T35 (C8): the SAME `rt` the menu was built with. Without it this validator would refuse
|
| 1180 |
+
# a tenant-gated kind it had just offered the model β the draft door disagreeing with the save
|
| 1181 |
+
# door about one tenant, which reads as "the assistant produced an invalid flow".
|
| 1182 |
cleaned, why = engine.clean_actions([{"kind": a.get("kind"), "config": a.get("config") or {}}
|
| 1183 |
+
for a in wrote], rt=session.runtime)
|
| 1184 |
if why or cleaned is None:
|
| 1185 |
raise err(400, "draft_invalid",
|
| 1186 |
f"the assistant produced a flow this deployment will not accept: {why}")
|
api/routes_feedback.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_feedback.py β WAVE 35 (ruling R8, contract C6): FEEDBACK GOES TO THE OPERATOR PLANE.
|
| 2 |
+
|
| 3 |
+
R8: *"Feedback goes to the platform-wide `loopable` operator plane. No tenant sees another's, and
|
| 4 |
+
the submitting tenant cannot edit or delete what it sent."*
|
| 5 |
+
|
| 6 |
+
ββ TWO DOORS, TWO DIFFERENT WALLS, AND THAT ASYMMETRY IS THE WHOLE TICKET. D-221 is already on the
|
| 7 |
+
register for a cross-tenant WRITE gated on `admin_gate` alone, so getting this backwards is a repeat
|
| 8 |
+
rather than a novelty:
|
| 9 |
+
|
| 10 |
+
POST /feedback ANY authenticated session, any tenant. It is the TENANT'S OWN ACT.
|
| 11 |
+
GET /feedback `core.platform_admin.is_platform_admin` ONLY. It is the OPERATOR'S read.
|
| 12 |
+
|
| 13 |
+
A single wall cannot express that. Gating the write on the operator predicate would mean nobody can
|
| 14 |
+
send feedback; gating the read on `admin_gate` would hand every tenant admin every other tenant's
|
| 15 |
+
words. So the walls are declared per route, and `verify_api` drives BOTH from a tenant admin, a
|
| 16 |
+
plain user and a platform admin rather than asserting the source.
|
| 17 |
+
|
| 18 |
+
β THE PROVENANCE IS STAMPED SERVER-SIDE AND NEVER READ FROM THE BODY. `tenant`, `user` and `ts`
|
| 19 |
+
come from the verified session and the clock. A body-supplied tenant would let any account file
|
| 20 |
+
words against another company's name, in a store only the operator reads and therefore only the
|
| 21 |
+
operator could ever be misled by.
|
| 22 |
+
|
| 23 |
+
β NO EDIT AND NO DELETE EXIST AT ALL β not for the tenant (R8 says so) and not for the operator
|
| 24 |
+
either. R8 grants the operator a READ; a delete door is a capability nobody asked for on a store
|
| 25 |
+
whose whole value is that it is append-only. There is nothing here to forget to wall.
|
| 26 |
+
|
| 27 |
+
β ONE STORE, AND WHERE IT PHYSICALLY LIVES IS STATED RATHER THAN IMPLIED. This uses `core.store`
|
| 28 |
+
with a raw key, which is the same thing `routes_platform_admin` does for `runtime.TENANTS_KEY` and
|
| 29 |
+
for the same reason: a platform fact belongs in the default store, not inside any tenant's
|
| 30 |
+
namespace. β That default is currently tenant #0's dataset repo (`OS_DATA_REPO`), so "the platform
|
| 31 |
+
store" and "tenant #0's repo" are the same bucket today. That is pre-existing (the control plane's
|
| 32 |
+
tenant records are already there) and it is a hosting fact, not a permission one β no tenant route
|
| 33 |
+
can read this key, because none of them names it.
|
| 34 |
+
"""
|
| 35 |
+
import time
|
| 36 |
+
|
| 37 |
+
from fastapi import APIRouter, Body, Depends
|
| 38 |
+
|
| 39 |
+
import core.platform_admin as platform_admin
|
| 40 |
+
import core.store as store
|
| 41 |
+
from deps import Session, err, require_session
|
| 42 |
+
|
| 43 |
+
router = APIRouter(prefix="/api/v1")
|
| 44 |
+
|
| 45 |
+
#: The platform-wide bucket. `{"rows": [ {id, tenant, user, category, text, ts} ]}` β newest LAST
|
| 46 |
+
#: in the store, newest FIRST on the wire.
|
| 47 |
+
STORE_KEY = "platform_feedback"
|
| 48 |
+
|
| 49 |
+
#: β THE CATEGORY VOCABULARY IS SERVER-OWNED AND SERVED (B's `ASK B-1 (3)`, agreed). A client
|
| 50 |
+
#: constant beside a server enum drifts, and the drift shows up as a dropdown offering a value the
|
| 51 |
+
#: door refuses. B renders exactly this list; the door accepts exactly these keys.
|
| 52 |
+
CATEGORIES = (
|
| 53 |
+
{"key": "bug", "label": "Something is broken"},
|
| 54 |
+
{"key": "idea", "label": "An idea or a request"},
|
| 55 |
+
{"key": "data", "label": "The numbers look wrong"},
|
| 56 |
+
{"key": "speed", "label": "Something is slow"},
|
| 57 |
+
{"key": "other", "label": "Something else"},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
MAX_CHARS = 4000
|
| 61 |
+
|
| 62 |
+
#: A ceiling on the whole store. β REPORTED AT BOTH ENDS, never a silent drop: the submitter is
|
| 63 |
+
#: refused with the cause, and the operator's own payload says the store is full so they can act.
|
| 64 |
+
#: Dropping the OLDEST rows instead would delete the operator's earliest feedback to make room for
|
| 65 |
+
#: the newest, which is the one direction this store must never move in.
|
| 66 |
+
MAX_ROWS = 5000
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _rows():
|
| 70 |
+
try:
|
| 71 |
+
raw = store.get(STORE_KEY) or {}
|
| 72 |
+
except Exception:
|
| 73 |
+
return []
|
| 74 |
+
rows = raw.get("rows") if isinstance(raw, dict) else None
|
| 75 |
+
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("/feedback/form")
|
| 79 |
+
def feedback_form(session: Session = Depends(require_session)):
|
| 80 |
+
"""What the composer renders. Any session β every account may send feedback.
|
| 81 |
+
|
| 82 |
+
β It deliberately returns NO submissions. R8 gives the tenant no read of what it sent, so a
|
| 83 |
+
"your feedback" list would be a second door to a thing the ruling closed. The 201 is the receipt.
|
| 84 |
+
"""
|
| 85 |
+
return {"categories": [dict(c) for c in CATEGORIES], "maxChars": MAX_CHARS}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/feedback", status_code=201)
|
| 89 |
+
def submit_feedback(body: dict = Body(default=None),
|
| 90 |
+
session: Session = Depends(require_session)):
|
| 91 |
+
"""Send one piece of feedback to the operator plane. `{"category": key, "text": str}`.
|
| 92 |
+
|
| 93 |
+
β ANY authenticated session, deliberately β see the header. The provenance is stamped from the
|
| 94 |
+
VERIFIED session, never from the body.
|
| 95 |
+
"""
|
| 96 |
+
body = body if isinstance(body, dict) else {}
|
| 97 |
+
category = str(body.get("category") or "").strip()
|
| 98 |
+
text = str(body.get("text") or "").strip()
|
| 99 |
+
if category not in {c["key"] for c in CATEGORIES}:
|
| 100 |
+
# Named, and it names the fix: a client whose dropdown has drifted from this list is a
|
| 101 |
+
# client whose user is about to lose what they typed.
|
| 102 |
+
raise err(400, "unknown_category",
|
| 103 |
+
"that is not one of the feedback categories. Reload the page and try again")
|
| 104 |
+
if not text:
|
| 105 |
+
raise err(400, "bad_request", "type what you want to tell us")
|
| 106 |
+
if len(text) > MAX_CHARS:
|
| 107 |
+
raise err(400, "too_long",
|
| 108 |
+
f"feedback is limited to {MAX_CHARS:,} characters. Yours is {len(text):,}")
|
| 109 |
+
if not store.available():
|
| 110 |
+
raise err(503, "store_unavailable",
|
| 111 |
+
"feedback could not be sent right now. Nothing was saved.")
|
| 112 |
+
if len(_rows()) >= MAX_ROWS:
|
| 113 |
+
raise err(503, "feedback_full",
|
| 114 |
+
"the feedback store is full and we have been told. Nothing was saved, so please "
|
| 115 |
+
"send this again later")
|
| 116 |
+
row = {
|
| 117 |
+
# β EVERY ONE OF THESE FOUR IS SERVER-SIDE. A body-supplied tenant or user would let any
|
| 118 |
+
# account file words against somebody else's name in a store only the operator reads.
|
| 119 |
+
"tenant": str(session.tenant),
|
| 120 |
+
"user": str(session.uname),
|
| 121 |
+
"ts": int(time.time()),
|
| 122 |
+
"category": category,
|
| 123 |
+
"text": text,
|
| 124 |
+
}
|
| 125 |
+
row["id"] = f"fb_{row['ts']}_{abs(hash((row['tenant'], row['user'], text))) % 10**8:08d}"
|
| 126 |
+
|
| 127 |
+
def _up(data):
|
| 128 |
+
data = data if isinstance(data, dict) else {}
|
| 129 |
+
rows = data.get("rows")
|
| 130 |
+
data["rows"] = (rows if isinstance(rows, list) else []) + [row]
|
| 131 |
+
return data
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
# `flush='sync'`: a person pressed Send and is being told it landed. The coalescing mode is
|
| 135 |
+
# for autosaves nobody is waiting on.
|
| 136 |
+
store.update(STORE_KEY, _up)
|
| 137 |
+
except Exception:
|
| 138 |
+
raise err(503, "store_unavailable",
|
| 139 |
+
"feedback could not be sent right now. Nothing was saved.")
|
| 140 |
+
return {"ok": True, "id": row["id"]}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("/feedback")
|
| 144 |
+
def list_feedback(tenant: str = "", session: Session = Depends(require_session)):
|
| 145 |
+
"""R8's operator read: every tenant's feedback, newest first. PLATFORM ADMIN ONLY.
|
| 146 |
+
|
| 147 |
+
β THE WALL IS `core.platform_admin.is_platform_admin`, IMPORTED AND CALLED β the double lock
|
| 148 |
+
(the record flag AND the `loopable` tenant), never re-expressed here. A tenant admin is not
|
| 149 |
+
admitted: `role: 'admin'` is one company's authority over its own workspace, and this is every
|
| 150 |
+
company's words in one list.
|
| 151 |
+
β `?tenant=` NARROWS an already-admitted operator's view. It is a convenience, not a wall, and
|
| 152 |
+
it is applied after the gate so it can never be the thing that admits anybody.
|
| 153 |
+
"""
|
| 154 |
+
if not platform_admin.is_platform_admin(session.user):
|
| 155 |
+
raise err(403, "forbidden", "this is a Loopable operator surface")
|
| 156 |
+
rows = _rows()
|
| 157 |
+
if tenant:
|
| 158 |
+
want = str(tenant).strip().lower()
|
| 159 |
+
rows = [r for r in rows if str(r.get("tenant") or "").lower() == want]
|
| 160 |
+
rows = sorted(rows, key=lambda r: int(r.get("ts") or 0), reverse=True)
|
| 161 |
+
out = {"rows": [dict(r) for r in rows], "total": len(rows), "capacity": MAX_ROWS,
|
| 162 |
+
"tenants": sorted({str(r.get("tenant") or "") for r in _rows() if r.get("tenant")})}
|
| 163 |
+
if len(_rows()) >= MAX_ROWS:
|
| 164 |
+
# R6's second sentence, aimed at the person who can act: submissions are being REFUSED right
|
| 165 |
+
# now, and the operator is the only one who can see that from here.
|
| 166 |
+
out["note"] = ("the feedback store is at capacity, so new submissions are being refused. "
|
| 167 |
+
"Archive what you have read")
|
| 168 |
+
return out
|
api/routes_keychain.py
CHANGED
|
@@ -296,6 +296,114 @@ def _rel_reconnect(rt):
|
|
| 296 |
return True
|
| 297 |
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
def _own_row(session, entry_id):
|
| 300 |
"""The visible row for `entry_id`, or a 404. β A 404 rather than a 403 for an entry the
|
| 301 |
caller cannot see: telling a member that somebody else's personal credential EXISTS is the
|
|
|
|
| 296 |
return True
|
| 297 |
|
| 298 |
|
| 299 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββ W35-T45 / R11: THE ENV -> KEYCHAIN MIGRATION
|
| 300 |
+
#
|
| 301 |
+
# R11: *"Tenant #0's Odoo credential MIGRATES onto the keychain, with the environment kept as
|
| 302 |
+
# fallback."* The owner chose this over a read-only display row WITH THE MIGRATION RISK STATED, so
|
| 303 |
+
# the risk is what this block is mostly about.
|
| 304 |
+
#
|
| 305 |
+
# β WHAT WAS ALREADY TRUE, CHECKED BEFORE ANY OF IT WAS WRITTEN: the keychain-first-then-env
|
| 306 |
+
# RESOLVER has existed since the 2026-08-04 cutover. `harness/runtime.py::odoo_source` is already
|
| 307 |
+
# (1) a keychain `odoo` entry, (2) tenant #0's compiled env connector, (3) None for anybody else,
|
| 308 |
+
# and `visible_entries` already serves a non-admin the row WITHOUT a preview. So R11 is not "build
|
| 309 |
+
# a resolver" β it is "give tenant #0 the ROW", which is the only reason its Keychain page looks
|
| 310 |
+
# empty while its Odoo grids work.
|
| 311 |
+
#
|
| 312 |
+
# ββ AND THE ONE REAL HAZARD IS NOT THE CREDENTIAL, IT IS THE PAUSE FLAG. `odoo_flag_key()` returns
|
| 313 |
+
# the first keychain `odoo` entry id when one exists and `ENV_ODOO_FLAG_KEY` ("odoo-env") otherwise β
|
| 314 |
+
# so CREATING THE ENTRY MOVES THE ADDRESS THE PAUSE FLAG LIVES AT. A tenant #0 that was paused under
|
| 315 |
+
# `odoo-env` would come back UNPAUSED, silently, at the first boot after this ships: the connector
|
| 316 |
+
# resumes pulling live Odoo because a migration changed which key the freeze was stored under. That
|
| 317 |
+
# is D-259's exact shape (a pause written under one key and read under another) and
|
| 318 |
+
# [[a-guard-bound-to-a-role-stops-guarding-when-the-role-moves]]. The flag is carried across in the
|
| 319 |
+
# SAME pass, and the carry is asserted.
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _env_odoo_fields():
|
| 323 |
+
"""The four env values `odoo_client` reads, or `(None, why)` when they are not all present.
|
| 324 |
+
|
| 325 |
+
β ALL FOUR OR NOTHING, and this completeness check is load-bearing rather than defensive.
|
| 326 |
+
Creating the entry makes `odoo_source` resolve through branch 1 INSTEAD of the env β so a
|
| 327 |
+
PARTIAL migration would hand the connector `{url, db}` and no key and take tenant #0's Odoo
|
| 328 |
+
offline, on a deployment where it had been working. The env fallback cannot save it, because the
|
| 329 |
+
entry's existence is what turns the fallback off.
|
| 330 |
+
β The names are `odoo_client.py`'s own (`ODOO_URL`/`ODOO_DB`/`ODOO_USER`/`ODOO_API_KEY`) and the
|
| 331 |
+
field names are `harness/connectors/odoo.py`'s stored shape (`{url, db, user, api_key}`). Two
|
| 332 |
+
vocabularies meet here; nowhere else.
|
| 333 |
+
"""
|
| 334 |
+
want = (("url", "ODOO_URL"), ("db", "ODOO_DB"),
|
| 335 |
+
("user", "ODOO_USER"), ("api_key", "ODOO_API_KEY"))
|
| 336 |
+
got = {field: (os.environ.get(env) or "").strip() for field, env in want}
|
| 337 |
+
missing = sorted(env for field, env in want if not got[field])
|
| 338 |
+
if missing:
|
| 339 |
+
return None, (f"the environment is missing {', '.join(missing)}, and a partial credential "
|
| 340 |
+
f"would take this tenant's Odoo offline rather than migrate it")
|
| 341 |
+
return got, ""
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def migrate_env_odoo(rt):
|
| 345 |
+
"""R11 β put tenant #0's environment Odoo credential on its keychain, once. Returns a report.
|
| 346 |
+
|
| 347 |
+
`{"done": bool, "entry": id|"", "carried_pause": bool, "why": str}` β `why` is filled on every
|
| 348 |
+
path including the skips, because "already migrated", "no keychain key on this deployment" and
|
| 349 |
+
"the env is incomplete" are three different operator actions.
|
| 350 |
+
|
| 351 |
+
ββ IT MUST RUN IN THE CONTAINER, WHICH IS WHY `main.py` CALLS IT AND NO SCRIPT DOES. D-195,
|
| 352 |
+
measured three times: a developer's CLI write to the tenant store is reverted by the running
|
| 353 |
+
Space within a minute (download-modify-upload, last-write-wins) β and **the write reports success
|
| 354 |
+
every time**, then a fresh read confirms it, and it is gone by the next poll. A CLI migration here
|
| 355 |
+
would be a dry run that lies, and the thing it would lie about is a credential.
|
| 356 |
+
|
| 357 |
+
β FAIL-QUIET AND IDEMPOTENT. It runs on EVERY boot; the second one must be a no-op and a
|
| 358 |
+
third-party failure must not take the boot down.
|
| 359 |
+
"""
|
| 360 |
+
out = {"done": False, "entry": "", "carried_pause": False, "why": ""}
|
| 361 |
+
if not env_odoo_available(rt):
|
| 362 |
+
# Not tenant #0, or this deployment has no env Odoo at all. Both are normal states.
|
| 363 |
+
out["why"] = "this tenant has no environment Odoo credential to migrate"
|
| 364 |
+
return out
|
| 365 |
+
fields, why = _env_odoo_fields()
|
| 366 |
+
if not fields:
|
| 367 |
+
out["why"] = why
|
| 368 |
+
return out
|
| 369 |
+
# β READ THE PAUSE FLAG BEFORE THE WRITE. After the entry exists, `odoo_flag_key()` answers the
|
| 370 |
+
# NEW key and the old one is unreachable through the resolver β so the only moment this fact can
|
| 371 |
+
# be observed is now. [[undo-capture-before-the-write]] applied to a guard rather than to data.
|
| 372 |
+
try:
|
| 373 |
+
was_paused = bool(((rt.get(_CONNECTOR_FLAGS_KEY) or {})
|
| 374 |
+
.get(_ENV_ODOO_FLAG_KEY) or {}).get("paused"))
|
| 375 |
+
except Exception: # noqa: BLE001
|
| 376 |
+
was_paused = False
|
| 377 |
+
row, why = _kc().ensure_entry_of_type(
|
| 378 |
+
rt, "odoo", "Odoo (migrated from this deployment)", fields, "system")
|
| 379 |
+
if row is None:
|
| 380 |
+
out["why"] = why
|
| 381 |
+
return out
|
| 382 |
+
out["done"], out["entry"] = True, row["id"]
|
| 383 |
+
if was_paused:
|
| 384 |
+
# The freeze followed the credential. Without this the connector silently RESUMES pulling
|
| 385 |
+
# live Odoo at the first boot after the migration.
|
| 386 |
+
def _up(cur):
|
| 387 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 388 |
+
entry = dict(cur.get(row["id"]) or {})
|
| 389 |
+
entry["paused"] = True
|
| 390 |
+
entry["pausedBy"] = "system"
|
| 391 |
+
entry["pausedNote"] = ("carried over from the environment source when the credential "
|
| 392 |
+
"was migrated onto the keychain")
|
| 393 |
+
cur[row["id"]] = entry
|
| 394 |
+
return cur
|
| 395 |
+
|
| 396 |
+
try:
|
| 397 |
+
rt.update(_CONNECTOR_FLAGS_KEY, _up, flush="sync")
|
| 398 |
+
out["carried_pause"] = True
|
| 399 |
+
except Exception as exc: # noqa: BLE001
|
| 400 |
+
# β SAID OUT LOUD. A migration that moved the credential and lost the freeze is worse
|
| 401 |
+
# than one that did not run, so this is the one failure that must never be silent.
|
| 402 |
+
out["why"] = (f"the credential migrated but the PAUSE could not be carried over "
|
| 403 |
+
f"({type(exc).__name__}), so this tenant's Odoo is no longer frozen")
|
| 404 |
+
return out
|
| 405 |
+
|
| 406 |
+
|
| 407 |
def _own_row(session, entry_id):
|
| 408 |
"""The visible row for `entry_id`, or a 404. β A 404 rather than a 403 for an entry the
|
| 409 |
caller cannot see: telling a member that somebody else's personal credential EXISTS is the
|
api/routes_nav.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_query.py
CHANGED
|
@@ -355,13 +355,24 @@ def _from_chat(answer, provider):
|
|
| 355 |
return answer, None, provider, None
|
| 356 |
|
| 357 |
|
| 358 |
-
def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
|
| 359 |
"""Return ``(spec, said, provider, reason)`` without any source-data fallback.
|
| 360 |
|
| 361 |
``said`` is what the assistant SAID: the whole answer when it did not build a view, and the
|
| 362 |
sentence beside the view when it built one and talked as well. ``reason`` is set only when
|
| 363 |
something went wrong, so a prose ANSWER and a transport FAILURE are distinguishable one layer
|
| 364 |
up rather than both arriving as a bare sentence.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
"""
|
| 366 |
requested = str(model or MODEL_AUTO).strip().lower()
|
| 367 |
if requested not in model_choices():
|
|
@@ -384,6 +395,27 @@ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
|
|
| 384 |
return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else "not_configured"
|
| 385 |
|
| 386 |
import requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
last = None
|
| 388 |
for provider in providers:
|
| 389 |
try:
|
|
@@ -417,7 +449,12 @@ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None):
|
|
| 417 |
last = f"{provider['name']}: HTTP {response.status_code}"
|
| 418 |
continue
|
| 419 |
try:
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
said = _said(answer.get("content"))
|
| 422 |
calls = answer.get("tool_calls") or []
|
| 423 |
if calls:
|
|
@@ -667,6 +704,11 @@ def _citation_complete(citation):
|
|
| 667 |
|
| 668 |
def _public_view(view):
|
| 669 |
source = view["source"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
return {
|
| 671 |
"id": view["id"], "viewId": view["id"], "scope": source["database"],
|
| 672 |
"name": view["name"], "description": str(view.get("description") or ""),
|
|
@@ -674,6 +716,12 @@ def _public_view(view):
|
|
| 674 |
"explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"],
|
| 675 |
"virtual": True, "source": _safe(source), "view": _safe(view["view"]),
|
| 676 |
"citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
}
|
| 678 |
|
| 679 |
|
|
@@ -744,14 +792,88 @@ def list_queries(session: Session = Depends(require_session)):
|
|
| 744 |
return _public_state(_state(session), session)
|
| 745 |
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
@router.post("/query/{qid}/events")
|
| 748 |
def mutate_query_workspace(qid: str, body: dict = Body(default=None),
|
| 749 |
session: Session = Depends(require_session)):
|
| 750 |
"""The only grid-mutation transport for a virtual Query workspace.
|
| 751 |
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
"""
|
| 756 |
qid = str(qid)
|
| 757 |
body = body if isinstance(body, dict) else {}
|
|
@@ -772,9 +894,40 @@ def mutate_query_workspace(qid: str, body: dict = Body(default=None),
|
|
| 772 |
raise err(404, "unknown_query", "that Query artefact does not exist")
|
| 773 |
|
| 774 |
event_type = str(event["type"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
if event_type != "view_delete":
|
| 776 |
raise err(409, "query_workspace_immutable",
|
| 777 |
-
"
|
| 778 |
if str(event.get("viewId") or "") != qid:
|
| 779 |
raise err(400, "query_workspace_mismatch",
|
| 780 |
"a Query delete must name the same virtual artefact as its binding")
|
|
@@ -784,6 +937,43 @@ def mutate_query_workspace(qid: str, body: dict = Body(default=None),
|
|
| 784 |
"event": event_type, **deleted}
|
| 785 |
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
def _sources(body, target, session):
|
| 788 |
raw = body.get("sources") if isinstance(body, dict) else None
|
| 789 |
sources = [str(item).strip() for item in raw] if isinstance(raw, list) else []
|
|
@@ -826,8 +1016,10 @@ def _submit(body, session, chat=None):
|
|
| 826 |
assistant_message_id = _new_id("message")
|
| 827 |
# R16: the turns already on screen go with the question. `state` was read above, BEFORE this
|
| 828 |
# turn's own messages exist, so the replay is strictly the prior conversation.
|
|
|
|
|
|
|
| 829 |
spec, said, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat,
|
| 830 |
-
history=_history(state, thread_id))
|
| 831 |
view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, said, reason)
|
| 832 |
|
| 833 |
artifact = None
|
|
@@ -850,6 +1042,15 @@ def _submit(body, session, chat=None):
|
|
| 850 |
"question": question,
|
| 851 |
"explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source,
|
| 852 |
"view": _safe(view), "citationIds": [citation_id], "numeric": numeric,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
"model": provider, "requestedModel": selected_model}
|
| 854 |
|
| 855 |
assistant_message = {
|
|
|
|
| 355 |
return answer, None, provider, None
|
| 356 |
|
| 357 |
|
| 358 |
+
def _call_model(question, snapshot, model=MODEL_AUTO, chat=None, history=None, session=None):
|
| 359 |
"""Return ``(spec, said, provider, reason)`` without any source-data fallback.
|
| 360 |
|
| 361 |
``said`` is what the assistant SAID: the whole answer when it did not build a view, and the
|
| 362 |
sentence beside the view when it built one and talked as well. ``reason`` is set only when
|
| 363 |
something went wrong, so a prose ANSWER and a transport FAILURE are distinguishable one layer
|
| 364 |
up rather than both arriving as a bare sentence.
|
| 365 |
+
|
| 366 |
+
ββ W35-T31 Β· CONTRACT C7 (R9) β ``session`` IS HERE ONLY SO THE METER CAN BE TOLD WHOSE CALL
|
| 367 |
+
THIS WAS, and PRD amendment A1 is why it is an argument rather than ambient state: a
|
| 368 |
+
``ContextVar`` bound in ``deps.require_session`` reads back ``None`` inside this function on the
|
| 369 |
+
SAME thread, so a ledger built on one would have recorded nothing and shown a dashboard of
|
| 370 |
+
zeros indistinguishable from a quiet week.
|
| 371 |
+
|
| 372 |
+
β OPTIONAL, DELIBERATELY. ``verify_query`` calls this function directly with an injected
|
| 373 |
+
``chat`` and no session, and a required argument would have made every one of those calls a
|
| 374 |
+
signature change. An omitted session is COUNTED in ``usage_ledger.UNATTRIBUTED`` and reported
|
| 375 |
+
to an admin, never dropped.
|
| 376 |
"""
|
| 377 |
requested = str(model or MODEL_AUTO).strip().lower()
|
| 378 |
if requested not in model_choices():
|
|
|
|
| 395 |
return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else "not_configured"
|
| 396 |
|
| 397 |
import requests
|
| 398 |
+
|
| 399 |
+
import usage_ledger
|
| 400 |
+
|
| 401 |
+
def _book(provider, body):
|
| 402 |
+
"""ββ C7 β ONE LEDGER LINE PER PROVIDER RESPONSE THIS FUNCTION READS.
|
| 403 |
+
|
| 404 |
+
β HERE, INSIDE THE LADDER, AND NOT AT THE RETURN. The ladder tries providers in order and
|
| 405 |
+
a failed one has already SPENT tokens at that vendor; booking only the winner would report
|
| 406 |
+
a week cheaper than it was, which is the cost-surprise R13 cited. Every 200 this loop reads
|
| 407 |
+
gets a line, including the one whose answer turned out to be empty.
|
| 408 |
+
β `tokens_from` returns `(None, None)` when the provider did not say, and `None` is carried
|
| 409 |
+
through rather than coerced to 0 β a call booked at zero is an unmeasured call presented as
|
| 410 |
+
a free one. `usage_ledger` counts it as UNMEASURED so the total reads as a floor.
|
| 411 |
+
β `record` never raises, by its own contract, so this cannot break the assistant.
|
| 412 |
+
"""
|
| 413 |
+
ins, outs = usage_ledger.tokens_from(body)
|
| 414 |
+
usage_ledger.record(
|
| 415 |
+
"assistant", provider["name"], provider["model"], ins, outs,
|
| 416 |
+
total=usage_ledger.total_from(body),
|
| 417 |
+
st=getattr(session, "runtime", None), user=getattr(session, "uname", ""))
|
| 418 |
+
|
| 419 |
last = None
|
| 420 |
for provider in providers:
|
| 421 |
try:
|
|
|
|
| 449 |
last = f"{provider['name']}: HTTP {response.status_code}"
|
| 450 |
continue
|
| 451 |
try:
|
| 452 |
+
body = response.json()
|
| 453 |
+
# C7: booked from the RAW body, before anything below can raise on its shape. A
|
| 454 |
+
# provider that answered 200 and billed for it has spent tokens whether or not this
|
| 455 |
+
# function can read what it said.
|
| 456 |
+
_book(provider, body)
|
| 457 |
+
answer = body["choices"][0]["message"]
|
| 458 |
said = _said(answer.get("content"))
|
| 459 |
calls = answer.get("tool_calls") or []
|
| 460 |
if calls:
|
|
|
|
| 704 |
|
| 705 |
def _public_view(view):
|
| 706 |
source = view["source"]
|
| 707 |
+
# ββ W35-T25 Β· CONTRACT C4 β `edited` IS DERIVED, NEVER STORED, and that is deliberate.
|
| 708 |
+
# A stored boolean beside two specs is a third source of truth that can disagree with both;
|
| 709 |
+
# the only honest answer to "has this been changed" is "compare it". It also means a Revert
|
| 710 |
+
# that restores the spec clears the badge by construction rather than by remembering to.
|
| 711 |
+
original = view.get("original_spec")
|
| 712 |
return {
|
| 713 |
"id": view["id"], "viewId": view["id"], "scope": source["database"],
|
| 714 |
"name": view["name"], "description": str(view.get("description") or ""),
|
|
|
|
| 716 |
"explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"],
|
| 717 |
"virtual": True, "source": _safe(source), "view": _safe(view["view"]),
|
| 718 |
"citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]),
|
| 719 |
+
# β AN ARTEFACT MADE BEFORE THIS WAVE HAS NO ORIGINAL, so it reports `edited: false` and
|
| 720 |
+
# sends no `original_spec` β and the client shows neither the badge nor Revert. C4 is
|
| 721 |
+
# explicit that this is the right answer: a Revert with nothing to revert to is worse
|
| 722 |
+
# than an absent one, and it is the case an NC in `verify_query` covers.
|
| 723 |
+
"original_spec": _safe(original) if isinstance(original, dict) else None,
|
| 724 |
+
"edited": bool(isinstance(original, dict) and _safe(view["view"]) != _safe(original)),
|
| 725 |
}
|
| 726 |
|
| 727 |
|
|
|
|
| 792 |
return _public_state(_state(session), session)
|
| 793 |
|
| 794 |
|
| 795 |
+
# ββ W35-T25 Β· CONTRACT C4 (owner item 4 / R3) β WHICH SPEC MEMBERS AN EDIT MAY MOVE.
|
| 796 |
+
#
|
| 797 |
+
# β AN ALLOW-LIST, AND IT IS THE SECURITY BOUNDARY OF THIS WHOLE TICKET. The client sends a
|
| 798 |
+
# `SavedView`, a shape it composes itself, and merging it wholesale would let a caller rewrite
|
| 799 |
+
# `kind` (which decides the renderer and the citation's own claim), `aggregation` (the number this
|
| 800 |
+
# artefact CITED) or `name`. R3 opens the view SPEC: *"filters, sort, group, visible columns,
|
| 801 |
+
# widths"*, plus the row height and column order that carry them. Nothing else.
|
| 802 |
+
#
|
| 803 |
+
# β `aggregation` IS DELIBERATELY ABSENT even though a person can change it on an ordinary grid.
|
| 804 |
+
# The artefact's citation records `contributing_record_count` and an op computed from THIS
|
| 805 |
+
# aggregation; letting an edit move it would leave a cited number describing a calculation the
|
| 806 |
+
# artefact no longer performs, which is the one thing every provenance rule in this module exists
|
| 807 |
+
# to prevent.
|
| 808 |
+
#
|
| 809 |
+
# β A COMMENT, NOT A BARE MODULE-LEVEL STRING. `verify_prose` reads a free-floating string literal
|
| 810 |
+
# as candidate copy, so writing this as a `"""..."""` above the constant put an em dash into the
|
| 811 |
+
# gate's own census as a 296th finding. A `#` block is out of scope by construction.
|
| 812 |
+
QUERY_EDITABLE_SPEC = ("visible", "order", "widths", "filters", "filterConj", "sorts",
|
| 813 |
+
"groupBy", "rowHeightMode", "frozenCount", "colorBy", "display")
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def _clean_spec_edit(config, spec, fields):
|
| 817 |
+
"""Merge a client view config onto the artefact's spec, cleaned against ITS OWN fields.
|
| 818 |
+
|
| 819 |
+
β THE FIELD KEYS COME FROM THE STORED SOURCE, never from the request. The artefact carries
|
| 820 |
+
the snapshot's field list, so this needs no database read and cannot be widened by a caller
|
| 821 |
+
naming a column the snapshot did not have.
|
| 822 |
+
"""
|
| 823 |
+
keys = {str(field.get("key")) for field in (fields or []) if isinstance(field, dict)}
|
| 824 |
+
out = copy.deepcopy(spec) if isinstance(spec, dict) else {}
|
| 825 |
+
if not isinstance(config, dict):
|
| 826 |
+
return out
|
| 827 |
+
for member in QUERY_EDITABLE_SPEC:
|
| 828 |
+
if member not in config:
|
| 829 |
+
continue
|
| 830 |
+
value = config[member]
|
| 831 |
+
if member in ("visible", "order"):
|
| 832 |
+
cleaned = [str(key) for key in value if str(key) in keys] if isinstance(value, list) else []
|
| 833 |
+
# β An EMPTY visible list is refused rather than stored: `_validate` already treats
|
| 834 |
+
# "no columns" as a refusal at creation, and a grid showing nothing is not an edit
|
| 835 |
+
# somebody meant to make.
|
| 836 |
+
if member == "visible" and not cleaned:
|
| 837 |
+
continue
|
| 838 |
+
out[member] = cleaned[:MAX_VISIBLE]
|
| 839 |
+
elif member == "widths":
|
| 840 |
+
out[member] = {str(key): int(width) for key, width in value.items()
|
| 841 |
+
if str(key) in keys and isinstance(width, (int, float))
|
| 842 |
+
and 0 < float(width) <= 2000} if isinstance(value, dict) else {}
|
| 843 |
+
elif member == "filters":
|
| 844 |
+
out[member] = _grid().clean_filter_tree(
|
| 845 |
+
[item for item in value if isinstance(item, dict)], keys) if isinstance(value, list) else []
|
| 846 |
+
elif member == "filterConj":
|
| 847 |
+
out[member] = "or" if value == "or" else "and"
|
| 848 |
+
elif member == "sorts":
|
| 849 |
+
out[member] = [{"colId": item["colId"], "dir": "desc" if item.get("dir") == "desc" else "asc"}
|
| 850 |
+
for item in value if isinstance(item, dict) and item.get("colId") in keys][:3] \
|
| 851 |
+
if isinstance(value, list) else []
|
| 852 |
+
elif member in ("groupBy", "colorBy"):
|
| 853 |
+
out[member] = value if value in keys else None
|
| 854 |
+
elif member == "rowHeightMode":
|
| 855 |
+
out[member] = value if value in ("short", "medium", "tall", "extra") else None
|
| 856 |
+
elif member == "frozenCount":
|
| 857 |
+
out[member] = max(0, min(6, int(value))) if isinstance(value, (int, float)) else 0
|
| 858 |
+
elif member == "display":
|
| 859 |
+
out[member] = _grid()._clean_display(value, keys) if isinstance(value, dict) else out.get("display")
|
| 860 |
+
return out
|
| 861 |
+
|
| 862 |
+
|
| 863 |
@router.post("/query/{qid}/events")
|
| 864 |
def mutate_query_workspace(qid: str, body: dict = Body(default=None),
|
| 865 |
session: Session = Depends(require_session)):
|
| 866 |
"""The only grid-mutation transport for a virtual Query workspace.
|
| 867 |
|
| 868 |
+
ββ W35-T25 (owner item 4 / R2) β `view_upsert` ON THE ARTEFACT ITSELF IS NOW ACCEPTED. It
|
| 869 |
+
used to 409 `query_workspace_immutable` for everything but a delete, on the reading that an AI
|
| 870 |
+
artefact is a snapshot. R2 replaces that reading: the SPEC is the reader's to shape, the
|
| 871 |
+
CITATION and the numbers behind it are not. `QUERY_EDITABLE_SPEC` above is where that line is
|
| 872 |
+
drawn, and `original_spec` is what makes the change undoable.
|
| 873 |
+
|
| 874 |
+
Creating a SECOND view inside the workspace is still refused β an artefact holds exactly one β
|
| 875 |
+
and deleting the caller's personal artefact still removes it. The binding key, not a
|
| 876 |
+
client-supplied source scope, identifies it.
|
| 877 |
"""
|
| 878 |
qid = str(qid)
|
| 879 |
body = body if isinstance(body, dict) else {}
|
|
|
|
| 894 |
raise err(404, "unknown_query", "that Query artefact does not exist")
|
| 895 |
|
| 896 |
event_type = str(event["type"])
|
| 897 |
+
if event_type == "view_upsert":
|
| 898 |
+
sent = event.get("view")
|
| 899 |
+
sent = sent if isinstance(sent, dict) else {}
|
| 900 |
+
# β THE ID IS CHECKED THE SAME WAY THE DELETE'S IS. An upsert naming a different view is
|
| 901 |
+
# a create wearing an update's name, and a Query workspace holds exactly one view.
|
| 902 |
+
if str(sent.get("id") or "") != qid:
|
| 903 |
+
raise err(400, "query_workspace_mismatch",
|
| 904 |
+
"a Query view edit must name the same virtual artefact as its binding")
|
| 905 |
+
spec = _clean_spec_edit(sent.get("config"), view.get("view"), (view.get("source") or {}).get("fields"))
|
| 906 |
+
|
| 907 |
+
def apply_edit(raw):
|
| 908 |
+
current = copy.deepcopy(raw) if isinstance(raw, dict) else _blank_state()
|
| 909 |
+
row = (current.get("views") or {}).get(qid)
|
| 910 |
+
if not isinstance(row, dict):
|
| 911 |
+
return current
|
| 912 |
+
# β BACKFILLED HERE, and only when absent: an artefact created before this wave has no
|
| 913 |
+
# original, and the FIRST edit is the last moment its pre-edit spec still exists. Not
|
| 914 |
+
# backfilling would leave it permanently unrevertable; backfilling unconditionally
|
| 915 |
+
# would make Revert restore the latest edit.
|
| 916 |
+
if not isinstance(row.get("original_spec"), dict):
|
| 917 |
+
row["original_spec"] = _safe(row.get("view"))
|
| 918 |
+
row["view"] = _safe(spec)
|
| 919 |
+
current["views"][qid] = row
|
| 920 |
+
return current
|
| 921 |
+
|
| 922 |
+
if not session.runtime.available():
|
| 923 |
+
raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was saved")
|
| 924 |
+
session.runtime.update(_namespace_key(session), apply_edit, flush="async")
|
| 925 |
+
return {"workspaceBinding": {"kind": "query", "key": qid}, "event": event_type,
|
| 926 |
+
"view": _public_view(apply_edit(_state(session))["views"][qid])}
|
| 927 |
+
|
| 928 |
if event_type != "view_delete":
|
| 929 |
raise err(409, "query_workspace_immutable",
|
| 930 |
+
"a Query workspace holds one view, so it cannot take another")
|
| 931 |
if str(event.get("viewId") or "") != qid:
|
| 932 |
raise err(400, "query_workspace_mismatch",
|
| 933 |
"a Query delete must name the same virtual artefact as its binding")
|
|
|
|
| 937 |
"event": event_type, **deleted}
|
| 938 |
|
| 939 |
|
| 940 |
+
@router.post("/query/{qid}/revert")
|
| 941 |
+
def revert_query(qid: str, session: Session = Depends(require_session)):
|
| 942 |
+
"""ββ CONTRACT C4 (R3) β restore the AI's original SPEC, and only the spec.
|
| 943 |
+
|
| 944 |
+
β WHAT THIS DOES NOT DO, which the client's confirm says out loud BEFORE it acts: it does not
|
| 945 |
+
undo anything the reader changed in the SOURCE database. A Query view is live now, so a cell
|
| 946 |
+
edit made through it is a real write to a real record β reverting a view's filters cannot and
|
| 947 |
+
must not walk those back. R3 is explicit that the dialog states this before it acts, because a
|
| 948 |
+
Revert that silently leaves data changed is worse than one that never offered.
|
| 949 |
+
|
| 950 |
+
β 409, not 404, when there is no original: the artefact exists and is readable, and the caller
|
| 951 |
+
asked for something that does not exist FOR IT. A 404 would say the artefact is gone.
|
| 952 |
+
"""
|
| 953 |
+
qid = str(qid)
|
| 954 |
+
# β `_artifact_or_404` returns `(state, row)`, not the row. Read it as a pair.
|
| 955 |
+
_state_now, view = _artifact_or_404(session, qid)
|
| 956 |
+
original = view.get("original_spec")
|
| 957 |
+
if not isinstance(original, dict):
|
| 958 |
+
raise err(409, "no_original_spec",
|
| 959 |
+
"this view was created before the assistant kept an original, so there is "
|
| 960 |
+
"nothing to revert to")
|
| 961 |
+
|
| 962 |
+
def restore(raw):
|
| 963 |
+
current = copy.deepcopy(raw) if isinstance(raw, dict) else _blank_state()
|
| 964 |
+
row = (current.get("views") or {}).get(qid)
|
| 965 |
+
if not isinstance(row, dict):
|
| 966 |
+
return current
|
| 967 |
+
row["view"] = _safe(row.get("original_spec"))
|
| 968 |
+
current["views"][qid] = row
|
| 969 |
+
return current
|
| 970 |
+
|
| 971 |
+
if not session.runtime.available():
|
| 972 |
+
raise err(503, "store_unavailable", "the tenant store is unavailable; nothing was reverted")
|
| 973 |
+
session.runtime.update(_namespace_key(session), restore, flush="async")
|
| 974 |
+
return _public_view(restore(_state(session))["views"][qid])
|
| 975 |
+
|
| 976 |
+
|
| 977 |
def _sources(body, target, session):
|
| 978 |
raw = body.get("sources") if isinstance(body, dict) else None
|
| 979 |
sources = [str(item).strip() for item in raw] if isinstance(raw, list) else []
|
|
|
|
| 1016 |
assistant_message_id = _new_id("message")
|
| 1017 |
# R16: the turns already on screen go with the question. `state` was read above, BEFORE this
|
| 1018 |
# turn's own messages exist, so the replay is strictly the prior conversation.
|
| 1019 |
+
# β C7/T31: `session` rides along so the meter can attribute this call. See `_call_model`'s
|
| 1020 |
+
# docstring for why it is an argument and not ambient state (PRD amendment A1).
|
| 1021 |
spec, said, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat,
|
| 1022 |
+
history=_history(state, thread_id), session=session)
|
| 1023 |
view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, said, reason)
|
| 1024 |
|
| 1025 |
artifact = None
|
|
|
|
| 1042 |
"question": question,
|
| 1043 |
"explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source,
|
| 1044 |
"view": _safe(view), "citationIds": [citation_id], "numeric": numeric,
|
| 1045 |
+
# ββ W35-T25 Β· CONTRACT C4 (R3) β THE AI'S OWN SPEC, WRITTEN ONCE, HERE.
|
| 1046 |
+
# R2 makes a Query view editable, so `view` moves from now on. This is the copy
|
| 1047 |
+
# "Revert to AI original" restores, and the thing `edited` is measured against.
|
| 1048 |
+
# β WRITTEN AT CREATE AND NOWHERE ELSE. Re-stamping it on any later write would
|
| 1049 |
+
# make Revert restore the most recent edit β a Revert that reverts to nothing,
|
| 1050 |
+
# which C4 names as worse than no Revert at all.
|
| 1051 |
+
# β `_safe(view)` twice, not the same object twice: `view` is mutable and a
|
| 1052 |
+
# shared reference would let an edit rewrite the original through the alias.
|
| 1053 |
+
"original_spec": _safe(view),
|
| 1054 |
"model": provider, "requestedModel": selected_model}
|
| 1055 |
|
| 1056 |
assistant_message = {
|
api/routes_starred.py
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_starred.py β WAVE 35 (rulings R4/R5/R6/R7, contracts C2/C3/C5): THE STAR.
|
| 2 |
+
|
| 3 |
+
Owner items 8, 9 and 12. **Star and "mark important" are ONE idea and ONE flag** (R4): on screen
|
| 4 |
+
the word is Star, and starring an object lands it on Home and in the new Starred module. Four
|
| 5 |
+
kinds are starrable β `database`, `agent`, `query`, `view` β and RECORDS get their own door
|
| 6 |
+
(C5, `/starred/records`), because a record's identity is `(database, pid)` rather than a bare id.
|
| 7 |
+
|
| 8 |
+
ββ THE ONE DESIGN RULE THAT DECIDES EVERYTHING BELOW: **A VIEW'S STAR IS NOT STORED HERE.**
|
| 9 |
+
C2 is explicit β `kind: "view"` writes the EXISTING `config.important` in that database's own
|
| 10 |
+
`<key>_table_workspace`, and never a second store. R4 says star and mark-important are one flag;
|
| 11 |
+
two stores for one flag is two flags that can disagree, and the badge inside the database and the
|
| 12 |
+
row on Home would then answer differently about the same view. So this file keeps THREE id lists
|
| 13 |
+
(`databases`, `agents`, `queries`) in its own per-user bucket, and for views it is a READER and a
|
| 14 |
+
WRITER of somebody else's bucket rather than an owner of anything.
|
| 15 |
+
|
| 16 |
+
β WHICH IS WHY `GET /starred` IS NOT FREE, AND EVERY CALLER MUST KNOW. Finding the starred views
|
| 17 |
+
means reading each visible database's view bucket β the same N reads `routes_nav._important_counts`
|
| 18 |
+
did before W35-T43 deleted it. Measured on tenant #0 (2026-08-16, recorded on that constant):
|
| 19 |
+
twelve buckets cost **6.2 ms WARM in total** and **7,331 ms of first fetch COLD**. So the scan is
|
| 20 |
+
budgeted, it is spent in the caller's own RECENTS order (a mark lives on a database somebody works
|
| 21 |
+
in), and a database it could not reach is named in `degraded` rather than quietly carrying no star.
|
| 22 |
+
That is R6's second sentence applied to a list: a limit that cannot be removed is REPORTED with its
|
| 23 |
+
cause, never silently enforced. **Call this AFTER first paint.**
|
| 24 |
+
|
| 25 |
+
β NO INDEX, DELIBERATELY. The obvious fix for the scan is a `starred.views` id list written beside
|
| 26 |
+
the three above. That is the second store C2 forbids: the moment the flag and the index can
|
| 27 |
+
disagree β a view deleted, a view unmarked from inside the database, a store write that lands on one
|
| 28 |
+
and not the other β the product has two answers to one question and no way to tell which is right.
|
| 29 |
+
The scan is the price of one flag, and it is paid off the critical path.
|
| 30 |
+
"""
|
| 31 |
+
import time
|
| 32 |
+
|
| 33 |
+
from fastapi import APIRouter, Body, Depends
|
| 34 |
+
|
| 35 |
+
import routes_nav
|
| 36 |
+
from deps import Session, err, require_session
|
| 37 |
+
|
| 38 |
+
router = APIRouter(prefix="/api/v1")
|
| 39 |
+
|
| 40 |
+
#: The per-user bucket holding the three ID-LIST kinds. `{username: {databases, agents, queries}}`
|
| 41 |
+
#:
|
| 42 |
+
#: β PER USER (R6), like `nav_recents` and unlike `nav_meta`: what I care about is not a fact about
|
| 43 |
+
#: the object, so a tenant-wide star would put one person's shortlist on everybody's Home.
|
| 44 |
+
_STARRED_KEY = "starred"
|
| 45 |
+
|
| 46 |
+
#: The four kinds C2 declares. `view` is the odd one out β see the header.
|
| 47 |
+
KINDS = ("database", "agent", "query", "view")
|
| 48 |
+
|
| 49 |
+
#: The three kinds this file's own bucket holds, and the payload key each answers under.
|
| 50 |
+
_LIST_KINDS = {"database": "databases", "agent": "agents", "query": "queries"}
|
| 51 |
+
|
| 52 |
+
#: A bound on each id list. β REPORTED, NEVER SILENT: a POST that would cross it is refused with
|
| 53 |
+
#: the cause and the action (unstar something), rather than being accepted and truncated. A star
|
| 54 |
+
#: silently dropped is a star the user believes they set.
|
| 55 |
+
MAX_PER_KIND = 200
|
| 56 |
+
|
| 57 |
+
#: An id is a store key, a view id or an automation id β all of which are short. Bounded so a
|
| 58 |
+
#: hostile client cannot grow one user's document with one very long string.
|
| 59 |
+
_MAX_ID = 120
|
| 60 |
+
|
| 61 |
+
#: The wall-clock ceiling on the WHOLE view scan, checked between databases β the same number and
|
| 62 |
+
#: the same reason as the `_IMPORTANT_BUDGET_S` this replaces. It bounds the FIRST request after a
|
| 63 |
+
#: container starts; warm it is not reached.
|
| 64 |
+
VIEW_SCAN_BUDGET_S = 2.5
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _clean_id(raw):
|
| 68 |
+
"""One id, or "" β the only shape this file stores."""
|
| 69 |
+
return str(raw or "").strip()[:_MAX_ID]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _clean_list(raw):
|
| 73 |
+
"""A stored id list, validated on the way OUT as well as in.
|
| 74 |
+
|
| 75 |
+
Re-validating a read is not redundant: the bucket outlives this code and an older build (or a
|
| 76 |
+
hand edit) could have written a shape this one does not accept. Prune, never invent β the
|
| 77 |
+
posture `_clean_nav_prefs` takes one file over.
|
| 78 |
+
"""
|
| 79 |
+
if not isinstance(raw, list):
|
| 80 |
+
return []
|
| 81 |
+
out, seen = [], set()
|
| 82 |
+
for item in raw[:MAX_PER_KIND]:
|
| 83 |
+
got = _clean_id(item)
|
| 84 |
+
if got and got not in seen:
|
| 85 |
+
seen.add(got)
|
| 86 |
+
out.append(got)
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _read_lists(session):
|
| 91 |
+
"""This caller's three id lists. A store blip answers empty rather than taking the page down."""
|
| 92 |
+
try:
|
| 93 |
+
mine = (session.runtime.get(_STARRED_KEY) or {}).get(session.uname) or {}
|
| 94 |
+
except Exception:
|
| 95 |
+
return {name: [] for name in _LIST_KINDS.values()}
|
| 96 |
+
if not isinstance(mine, dict):
|
| 97 |
+
return {name: [] for name in _LIST_KINDS.values()}
|
| 98 |
+
return {name: _clean_list(mine.get(name)) for name in _LIST_KINDS.values()}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _database_keys(session):
|
| 102 |
+
"""`(keys, enumerated)` β every database this session may open that has a view bucket.
|
| 103 |
+
|
| 104 |
+
β `routes_nav._visible_database_keys` IS REUSED RATHER THAN RE-DERIVED, and that is not
|
| 105 |
+
tidiness: it merges the compiled registry (behind the account grant AND the tenant catalogue)
|
| 106 |
+
with `user_tables.nav_entries`, the `may_open`-filtered listing over the rows-free projection.
|
| 107 |
+
Writing a second enumeration here is how the folder-drop defect happened (wave 27, item 6): one
|
| 108 |
+
door knew about `ut_*` keys and the other did not, and the disagreeing door answered 200 while
|
| 109 |
+
storing nothing.
|
| 110 |
+
β AND IT IS NOT `_placeable_top_keys`, WHICH NEVER APPLIES THE TENANT CATALOGUE β right for a
|
| 111 |
+
cosmetic folder placement, wrong for a scan that then opens each database's view bucket. See
|
| 112 |
+
that helper's own note for the second difference (module surfaces have no views to star).
|
| 113 |
+
"""
|
| 114 |
+
return routes_nav._visible_database_keys(session)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _scan_order(session, keys):
|
| 118 |
+
"""`keys`, RECENTS-FIRST β the order the scan budget is spent in.
|
| 119 |
+
|
| 120 |
+
β THE ORDER IS LOAD-BEARING AND W34-T10 LEARNED IT THE EXPENSIVE WAY. A budget spent in
|
| 121 |
+
whatever order the keys happen to arrive is a lottery: measured cold on tenant #0, that block
|
| 122 |
+
spent all of its first draft's budget on ten EMPTY Odoo buckets and was cut off two rows before
|
| 123 |
+
`customer_data`, which holds the only marked view in the tenant. The feature would have been
|
| 124 |
+
correct and shown nothing. A star lives on a database somebody works in, and `nav_recents` is
|
| 125 |
+
exactly that list, newest first.
|
| 126 |
+
"""
|
| 127 |
+
recents = routes_nav._read_recents(session.runtime, session.uname, keys)
|
| 128 |
+
order = [r["key"] for r in recents]
|
| 129 |
+
seen = set(order)
|
| 130 |
+
order += [k for k in sorted(keys) if k not in seen]
|
| 131 |
+
return order
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _view_docs(session, order):
|
| 135 |
+
"""`({key: workspace_doc}, unread)` β each database's view bucket, PROJECTED, within budget.
|
| 136 |
+
|
| 137 |
+
β PROJECTED, and the two dropped keys are what makes this affordable: `overlays` is per-record
|
| 138 |
+
cell values and `fields` is the schema stratum, neither of which says anything about a view. On
|
| 139 |
+
`customer_data` they are most of the bucket.
|
| 140 |
+
β `unread` is a store blip OR the budget β reported, never a confident absence.
|
| 141 |
+
"""
|
| 142 |
+
import core.view_templates as view_templates
|
| 143 |
+
docs, unread = {}, []
|
| 144 |
+
started = time.perf_counter()
|
| 145 |
+
for key in order:
|
| 146 |
+
ws_key = view_templates.workspace_key(key)
|
| 147 |
+
if not ws_key:
|
| 148 |
+
continue # a module surface or a folder head, not a table at all
|
| 149 |
+
if time.perf_counter() - started > VIEW_SCAN_BUDGET_S:
|
| 150 |
+
unread.append(key)
|
| 151 |
+
continue
|
| 152 |
+
try:
|
| 153 |
+
docs[key] = session.runtime.get_projection(ws_key,
|
| 154 |
+
drop=("overlays", "fields")) or {}
|
| 155 |
+
except Exception:
|
| 156 |
+
unread.append(key)
|
| 157 |
+
return docs, unread
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _refresh_one(session, cache, key):
|
| 161 |
+
"""Re-read ONE database's bucket inside a cache the caller already holds.
|
| 162 |
+
|
| 163 |
+
β THIS IS WHY A WRITE MAY REUSE A SCAN AT ALL. The star write changes exactly one bucket, so
|
| 164 |
+
every OTHER document in the cache is still true β but the one just written is stale by
|
| 165 |
+
definition, and serving it back would answer the POST with the flag's OLD value. That is a lost
|
| 166 |
+
write wearing the face of a failed read ([[refetch-eats-its-own-write]] in reverse), and it is
|
| 167 |
+
the trap that makes "just reuse the cache" wrong without this function.
|
| 168 |
+
"""
|
| 169 |
+
import core.view_templates as view_templates
|
| 170 |
+
order, docs, unread = cache
|
| 171 |
+
ws_key = view_templates.workspace_key(key)
|
| 172 |
+
if not ws_key:
|
| 173 |
+
return cache
|
| 174 |
+
try:
|
| 175 |
+
docs[key] = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
|
| 176 |
+
except Exception:
|
| 177 |
+
docs.pop(key, None)
|
| 178 |
+
if key not in unread:
|
| 179 |
+
unread.append(key)
|
| 180 |
+
return (order, docs, unread)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _scan_starred_views(session, keys=None, cache=None):
|
| 184 |
+
"""`(rows, unread)` β every view this caller has starred, newest-worked database first.
|
| 185 |
+
|
| 186 |
+
A row is `{"id", "database", "name", "config"}`. β `config` is carried so the COUNTS route does
|
| 187 |
+
not have to read every bucket a second time: `_view_docs` is the expensive half of this file
|
| 188 |
+
(7,331 ms cold for twelve buckets) and two consumers asking the same question twice per request
|
| 189 |
+
is the 1+N shape D-175 spent a wave removing from `/nav`. `_starred_views` below drops it for the
|
| 190 |
+
wire.
|
| 191 |
+
|
| 192 |
+
β `cache` IS `(order, docs, unread)` FROM AN EARLIER PASS IN THE SAME REQUEST, and it exists
|
| 193 |
+
because the view-star WRITE otherwise scanned every bucket TWICE: once in `_find_view` to locate
|
| 194 |
+
the view, then again to build the answer. That is the same 1+N shape, in the same file, on the
|
| 195 |
+
same expensive read β introduced an hour after it was removed one function away. A caller that
|
| 196 |
+
has written must `_refresh_one` the database it wrote before passing its cache.
|
| 197 |
+
"""
|
| 198 |
+
if cache is not None:
|
| 199 |
+
order, docs, unread = cache
|
| 200 |
+
else:
|
| 201 |
+
if keys is None:
|
| 202 |
+
keys, _ = _database_keys(session)
|
| 203 |
+
order = _scan_order(session, keys)
|
| 204 |
+
docs, unread = _view_docs(session, order)
|
| 205 |
+
granted = routes_nav._granted_view_ids(session)
|
| 206 |
+
out = []
|
| 207 |
+
for key in order:
|
| 208 |
+
doc = docs.get(key)
|
| 209 |
+
if doc is None:
|
| 210 |
+
continue
|
| 211 |
+
for vid, view in routes_nav._visible_views(doc, session.uname, session.admin,
|
| 212 |
+
granted).items():
|
| 213 |
+
cfg = view.get("config") or {}
|
| 214 |
+
if cfg.get("important") is True:
|
| 215 |
+
out.append({"id": vid, "database": key,
|
| 216 |
+
"name": str(view.get("name") or vid)[:120], "config": cfg})
|
| 217 |
+
return out, unread
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _starred_views(session, keys=None, cache=None):
|
| 221 |
+
"""The C2 wire rows β `{"id", "database", "name"}`, with the config dropped.
|
| 222 |
+
|
| 223 |
+
β `name` is a SUPERSET of C2, published to B as `NOTE E-1`: the scan already holds the record,
|
| 224 |
+
so the name is free, and without it Home would render a starred view as a bare id.
|
| 225 |
+
β THE CONFIG DOES NOT GO ON THE WIRE. It carries `memberPids` (a curated view's whole pid list)
|
| 226 |
+
and every filter leaf, which is somebody's row set travelling to a client that asked for a label.
|
| 227 |
+
"""
|
| 228 |
+
rows, unread = _scan_starred_views(session, keys, cache=cache)
|
| 229 |
+
return ([{"id": r["id"], "database": r["database"], "name": r["name"]} for r in rows],
|
| 230 |
+
unread)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _query_names(session, qids):
|
| 234 |
+
"""`({qid: name}, resolved)` for the caller's own Query artefacts.
|
| 235 |
+
|
| 236 |
+
β ANSWERS `ASK B-1 (2)`. C2 declares `queries: [qid]` and there is no view/query id -> label map
|
| 237 |
+
anywhere in the client, so Home and Starred would render a starred Query as a bare id. B's only
|
| 238 |
+
alternative was `GET /query`, which drags threads, messages, citations and every saved view
|
| 239 |
+
across the wire to render two strings.
|
| 240 |
+
|
| 241 |
+
β IT READS LANE C's BUCKET AND CALLS LANE C's HELPERS, WITHOUT EDITING LANE C's FILE. Query
|
| 242 |
+
state is ONE small per-user document (`query_user_<hash>`: threads, messages, citations, views β
|
| 243 |
+
no grid rows), and `_namespace_key`/`_state` are pure functions of the session. Re-deriving the
|
| 244 |
+
key here would be a second spelling of a hash, which is the one thing guaranteed to drift.
|
| 245 |
+
|
| 246 |
+
β `resolved` IS FALSE WHEN THE READ FAILED, and the caller must not prune on it β a store blip
|
| 247 |
+
would otherwise delete somebody's whole starred-query list. Same posture as
|
| 248 |
+
`_clean_nav_prefs(keep_unknown_ut=...)`.
|
| 249 |
+
"""
|
| 250 |
+
if not qids:
|
| 251 |
+
return {}, True
|
| 252 |
+
try:
|
| 253 |
+
import routes_query
|
| 254 |
+
views = (routes_query._state(session) or {}).get("views") or {}
|
| 255 |
+
except Exception:
|
| 256 |
+
return {}, False
|
| 257 |
+
out = {}
|
| 258 |
+
for qid in qids:
|
| 259 |
+
row = views.get(qid)
|
| 260 |
+
if isinstance(row, dict):
|
| 261 |
+
out[qid] = str(row.get("name") or qid)[:120]
|
| 262 |
+
return out, True
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _payload(session, keys=None, cache=None):
|
| 266 |
+
"""The C2 answer: the three id lists, the starred views, and what could not be read.
|
| 267 |
+
|
| 268 |
+
β `degraded` SHIPS AS `[]` RATHER THAN BEING OMITTED WHEN EMPTY β the same rule `/nav`'s
|
| 269 |
+
`omitted`/`degraded` follow, for the same reason: a key a consumer has to test for is a key a
|
| 270 |
+
consumer forgets to test for.
|
| 271 |
+
β `cache` is a scan an earlier pass in THIS request already paid for β see `_scan_starred_views`.
|
| 272 |
+
"""
|
| 273 |
+
lists = _read_lists(session)
|
| 274 |
+
if keys is None:
|
| 275 |
+
keys, _ = _database_keys(session)
|
| 276 |
+
# β PRUNED ON READ, NEVER ON WRITE β `_read_recents`' posture, and both halves are deliberate.
|
| 277 |
+
# A database whose grant was revoked must stop being offered without anybody running a
|
| 278 |
+
# migration, and must come back if the grant does. A key that is not visible therefore reaches
|
| 279 |
+
# the store and never reaches a screen.
|
| 280 |
+
# β ONLY `databases` is pruned. An agent id or a query id would each cost another store read to
|
| 281 |
+
# validate, and pruning them here buys nothing: this list is the CALLER'S OWN writes, so it can
|
| 282 |
+
# disclose nothing they did not already know, and B/C render only objects they can already see
|
| 283 |
+
# (the way `groupRecents` drops an unreachable recent). Said out loud rather than left as a gap.
|
| 284 |
+
lists["databases"] = [k for k in lists["databases"] if k in keys]
|
| 285 |
+
# β ASK B-1 (2): the names, and β because the read that answers "what is it called" also answers
|
| 286 |
+
# "does it still exist" β the same prune the databases get. β ONLY WHEN THE READ SUCCEEDED: a
|
| 287 |
+
# store blip that returned no artefacts must not delete the whole starred-query list.
|
| 288 |
+
qnames, qresolved = _query_names(session, lists["queries"])
|
| 289 |
+
if qresolved:
|
| 290 |
+
lists["queries"] = [q for q in lists["queries"] if q in qnames]
|
| 291 |
+
views, unread = _starred_views(session, keys, cache=cache)
|
| 292 |
+
out = {"databases": lists["databases"], "agents": lists["agents"],
|
| 293 |
+
"queries": lists["queries"], "queryNames": qnames,
|
| 294 |
+
"views": views, "degraded": unread}
|
| 295 |
+
if unread:
|
| 296 |
+
# R6's second sentence, in the payload: the CAUSE and what it means, not a bare list.
|
| 297 |
+
out["note"] = (f"{len(unread)} database(s) could not be read in time, so a view you "
|
| 298 |
+
f"starred there is missing from this list. Open one of them once and the "
|
| 299 |
+
f"next load will include it")
|
| 300 |
+
return out
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@router.get("/starred")
|
| 304 |
+
def starred(session: Session = Depends(require_session)):
|
| 305 |
+
"""C2 β everything this caller has starred.
|
| 306 |
+
|
| 307 |
+
β NOT ON `/nav`'s PATH, and R7 is the ruling: `/nav` is a rows-free projection whose budget was
|
| 308 |
+
already the subject of D-175/D-185/D-288. This route is called AFTER Home/Starred paint.
|
| 309 |
+
"""
|
| 310 |
+
return _payload(session)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _find_view(session, vid, database=""):
|
| 314 |
+
"""`(key, ws_key, stratum, view, cache)` for a view this caller can SEE, else None.
|
| 315 |
+
|
| 316 |
+
`stratum` is the username whose block holds it, or `table_store.SHARED_KEY`. `cache` is the
|
| 317 |
+
`(order, docs, unread)` this call paid for β but **only when it scanned everything**. With a
|
| 318 |
+
`database` hint it read ONE bucket, which is not a complete answer for the caller's whole
|
| 319 |
+
starred list, so it hands back None and the caller pays for a full pass. Handing back a partial
|
| 320 |
+
cache would silently drop every starred view on every OTHER database.
|
| 321 |
+
|
| 322 |
+
β `database` IS AN OPTIONAL FAST PATH (a superset of C2, published to B and C): the client
|
| 323 |
+
almost always knows which database the view belongs to, and naming it turns an N-bucket scan
|
| 324 |
+
into one read. Absent, the scan runs β because C2's body carries only `kind`/`id`/`on`, and a
|
| 325 |
+
door that REQUIRED the hint would break the contract two lanes are building against.
|
| 326 |
+
"""
|
| 327 |
+
import core.table_store as table_store
|
| 328 |
+
import core.view_templates as view_templates
|
| 329 |
+
|
| 330 |
+
keys, _ = _database_keys(session)
|
| 331 |
+
if database:
|
| 332 |
+
# An unknown or invisible key must not become a wider scan. It answers "not found", which
|
| 333 |
+
# is the same answer an id nobody holds gets β see the write door's note on why.
|
| 334 |
+
if database not in keys:
|
| 335 |
+
return None
|
| 336 |
+
order, cache = [database], None
|
| 337 |
+
else:
|
| 338 |
+
order = _scan_order(session, keys)
|
| 339 |
+
docs, unread = _view_docs(session, order)
|
| 340 |
+
if not database:
|
| 341 |
+
cache = (order, docs, unread)
|
| 342 |
+
granted = routes_nav._granted_view_ids(session)
|
| 343 |
+
for key in order:
|
| 344 |
+
doc = docs.get(key)
|
| 345 |
+
if doc is None:
|
| 346 |
+
continue
|
| 347 |
+
if str(vid) not in routes_nav._visible_views(doc, session.uname, session.admin, granted):
|
| 348 |
+
continue
|
| 349 |
+
ws_key = view_templates.workspace_key(key)
|
| 350 |
+
# WHICH stratum holds it decides the WALL, so it is resolved here rather than guessed.
|
| 351 |
+
# Personal first: a caller's own block is the common case and needs no grant.
|
| 352 |
+
if isinstance(doc.get(session.uname), dict) and \
|
| 353 |
+
str(vid) in ((doc[session.uname].get("views") or {})):
|
| 354 |
+
return (key, ws_key, session.uname, doc[session.uname]["views"][str(vid)], cache)
|
| 355 |
+
shared = (doc.get(table_store.SHARED_KEY) or {}).get("views") or {}
|
| 356 |
+
if str(vid) in shared:
|
| 357 |
+
return (key, ws_key, table_store.SHARED_KEY, shared[str(vid)], cache)
|
| 358 |
+
for stratum, blob in doc.items():
|
| 359 |
+
if stratum == session.uname or not isinstance(blob, dict):
|
| 360 |
+
continue
|
| 361 |
+
if str(vid) in (blob.get("views") or {}):
|
| 362 |
+
return (key, ws_key, str(stratum), blob["views"][str(vid)], cache)
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _may_star_view(session, stratum, view):
|
| 367 |
+
"""May this caller WRITE the star on this view? Fail-closed.
|
| 368 |
+
|
| 369 |
+
β A VIEW ID THE CALLER CANNOT WRITE MUST REFUSE **LOUDLY**, and this is a standing scar:
|
| 370 |
+
`view_upsert` answers **200 while writing nothing** when the id belongs to a view the caller
|
| 371 |
+
cannot see, so a pinned id is effectively tenant-scoped and every symptom of it is silent
|
| 372 |
+
([[a-view-id-another-user-holds-refuses-silently]]). This function is the reason the write door
|
| 373 |
+
below raises instead of shrugging.
|
| 374 |
+
|
| 375 |
+
THREE HOMES, THREE WALLS:
|
| 376 |
+
Β· the caller's OWN stratum β theirs, no further question;
|
| 377 |
+
Β· `__shared__` β `table_store._may_edit`, which is the predicate the view door itself uses;
|
| 378 |
+
Β· another user's stratum, reached by a wave-21 grant β the grant must be `edit` or `owner`.
|
| 379 |
+
β A `view` ROLE IS NOT A WRITE GRANT. `_granted_view_ids` admits both roles because it
|
| 380 |
+
answers "can they SEE it"; this answers "may they CHANGE it", and collapsing the two would
|
| 381 |
+
let a read-only grantee mark somebody else's shared view.
|
| 382 |
+
"""
|
| 383 |
+
import core.shares as shares
|
| 384 |
+
import core.table_store as table_store
|
| 385 |
+
if stratum == session.uname:
|
| 386 |
+
return True
|
| 387 |
+
if stratum == table_store.SHARED_KEY:
|
| 388 |
+
return bool(table_store._may_edit(view, session.uname, session.admin))
|
| 389 |
+
return shares.role_for("view", str((view or {}).get("id") or ""), session.uname,
|
| 390 |
+
is_admin=session.admin,
|
| 391 |
+
st=session.runtime) in ("edit", "owner")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _write_view_star(session, ws_key, stratum, vid, on):
|
| 395 |
+
"""Set `config.important` IN PLACE, in the view's own bucket. Returns True when it landed.
|
| 396 |
+
|
| 397 |
+
ββ IN PLACE, AND **NOT** THROUGH `grid_events.view_upsert` OR `table_store.save_view` β three
|
| 398 |
+
separate reasons, each of which has cost this repo something:
|
| 399 |
+
|
| 400 |
+
1. `view_upsert` REBUILDS `config` from a literal allowlist and filters `memberPids` against
|
| 401 |
+
`allowed_pids` and `cohortLock` against `cohort_ids`. Routing a flag toggle through it
|
| 402 |
+
means a cohort view whose pool this request did not assemble comes back with its curated
|
| 403 |
+
row set pruned β "the 40 accounts we agreed to call" quietly becoming fewer, under the
|
| 404 |
+
same name, with nothing going red.
|
| 405 |
+
2. `table_store.save_view` re-runs `_unique_name` against every view name in the document.
|
| 406 |
+
A star has no business re-allocating a name; a collision would RENAME the starred view.
|
| 407 |
+
3. Both are "write the whole view" doors. This writes one boolean.
|
| 408 |
+
|
| 409 |
+
β SET UNCONDITIONALLY, never "only when True" β `grid_events` records the same rule beside its
|
| 410 |
+
own `important` line: a key written only when present means unstarring is silently a no-op and
|
| 411 |
+
the stored `true` survives, i.e. a mark you can set and never clear.
|
| 412 |
+
β `bool(on)`, so a JSON `"false"` cannot become a star.
|
| 413 |
+
β FLUSH IS THE DEFAULT (sync). This is a deliberate one-at-a-time act like a folder rename, not
|
| 414 |
+
the autosave hot path `table_store` uses `async` for β and a coalesced write is exactly what
|
| 415 |
+
made eighteen 200s land zero rows in wave 33.
|
| 416 |
+
"""
|
| 417 |
+
landed = {"ok": False}
|
| 418 |
+
|
| 419 |
+
def _up(data):
|
| 420 |
+
data = data if isinstance(data, dict) else {}
|
| 421 |
+
block = data.get(stratum)
|
| 422 |
+
if not isinstance(block, dict):
|
| 423 |
+
return data
|
| 424 |
+
views = block.get("views")
|
| 425 |
+
if not isinstance(views, dict) or str(vid) not in views:
|
| 426 |
+
return data
|
| 427 |
+
view = views[str(vid)]
|
| 428 |
+
if not isinstance(view, dict):
|
| 429 |
+
return data
|
| 430 |
+
cfg = view.get("config")
|
| 431 |
+
if not isinstance(cfg, dict):
|
| 432 |
+
cfg = {}
|
| 433 |
+
view["config"] = cfg
|
| 434 |
+
cfg["important"] = bool(on)
|
| 435 |
+
landed["ok"] = True
|
| 436 |
+
return data
|
| 437 |
+
|
| 438 |
+
session.runtime.update(ws_key, _up)
|
| 439 |
+
return landed["ok"]
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
@router.post("/starred")
|
| 443 |
+
def set_starred(body: dict = Body(default=None),
|
| 444 |
+
session: Session = Depends(require_session)):
|
| 445 |
+
"""C2 β star or unstar ONE object, and answer with the caller's whole new list.
|
| 446 |
+
|
| 447 |
+
Body: `{"kind": "database"|"agent"|"query"|"view", "id": str, "on": bool}`, plus an OPTIONAL
|
| 448 |
+
`"database"` when `kind` is `view` (the fast path β see `_find_view`).
|
| 449 |
+
|
| 450 |
+
β THE WHOLE LIST COMES BACK, not an ack, because the client is optimistic-then-reconciled
|
| 451 |
+
(T15): an ack would leave the browser as the only place that knows what the new state is.
|
| 452 |
+
"""
|
| 453 |
+
body = body if isinstance(body, dict) else {}
|
| 454 |
+
kind = str(body.get("kind") or "").strip()
|
| 455 |
+
oid = _clean_id(body.get("id"))
|
| 456 |
+
if kind not in KINDS:
|
| 457 |
+
raise err(400, "bad_request",
|
| 458 |
+
f"kind must be one of {', '.join(KINDS)}")
|
| 459 |
+
if not oid:
|
| 460 |
+
raise err(400, "bad_request", "no object was named")
|
| 461 |
+
if "on" not in body or not isinstance(body.get("on"), bool):
|
| 462 |
+
# Explicit rather than defaulted: a toggle whose default is "star" turns a malformed
|
| 463 |
+
# unstar into a star, which is the one direction a user cannot undo by repeating it.
|
| 464 |
+
raise err(400, "bad_request", "`on` must be true or false")
|
| 465 |
+
on = bool(body["on"])
|
| 466 |
+
if not session.runtime.available():
|
| 467 |
+
raise err(503, "store_unavailable",
|
| 468 |
+
"the tenant store is unavailable. Nothing was saved.")
|
| 469 |
+
|
| 470 |
+
if kind == "view":
|
| 471 |
+
found = _find_view(session, oid, _clean_id(body.get("database")))
|
| 472 |
+
if not found:
|
| 473 |
+
# β 404 FOR BOTH "no such view" AND "not yours to see", deliberately: a distinct answer
|
| 474 |
+
# would turn this route into an oracle that sorts real view ids from invented ones.
|
| 475 |
+
raise err(404, "unknown_view", "that view is not available")
|
| 476 |
+
db_key, ws_key, stratum, view, cache = found
|
| 477 |
+
if not _may_star_view(session, stratum, view):
|
| 478 |
+
raise err(403, "forbidden",
|
| 479 |
+
"that view belongs to somebody else and you have read access only")
|
| 480 |
+
try:
|
| 481 |
+
landed = _write_view_star(session, ws_key, stratum, oid, on)
|
| 482 |
+
except Exception:
|
| 483 |
+
raise err(503, "store_unavailable",
|
| 484 |
+
"the star was not saved: the store refused the write.")
|
| 485 |
+
if not landed:
|
| 486 |
+
# The view moved between the read and the write (deleted, shared, unshared). Loud,
|
| 487 |
+
# because a 200 over a write that changed nothing is the defect this file's own
|
| 488 |
+
# `_may_star_view` note is about.
|
| 489 |
+
raise err(409, "view_moved",
|
| 490 |
+
"that view changed while the star was being saved. Try again")
|
| 491 |
+
# β REUSE THE SCAN, REFRESH THE ONE BUCKET WE WROTE. Without this the POST scans every view
|
| 492 |
+
# bucket TWICE β once to find the view, once to build the answer β which is the 1+N shape
|
| 493 |
+
# this file removed from `/starred/counts` an hour earlier, reintroduced by the write path.
|
| 494 |
+
# β `_refresh_one` is not optional: the written document is stale by definition and serving
|
| 495 |
+
# it back would answer the POST with the flag's OLD value.
|
| 496 |
+
if cache is not None:
|
| 497 |
+
cache = _refresh_one(session, cache, db_key)
|
| 498 |
+
return _payload(session, cache=cache)
|
| 499 |
+
|
| 500 |
+
field = _LIST_KINDS[kind]
|
| 501 |
+
uname = session.uname
|
| 502 |
+
# β THE CAP IS ENFORCED INSIDE THE TRANSACTION, NOT BY A READ BEFORE IT, AND THAT IS NOT
|
| 503 |
+
# PEDANTRY. A pre-flight `len(current) >= MAX_PER_KIND` check is advisory: two concurrent stars
|
| 504 |
+
# both pass it, both append, and the document ends up at MAX+1 β where `_clean_list`'s
|
| 505 |
+
# `raw[:MAX_PER_KIND]` then drops one **on the next read**. That is a star the user set, saw
|
| 506 |
+
# confirmed, and cannot find, which is exactly the silent truncation R6's second sentence
|
| 507 |
+
# forbids. Refusing inside the read-modify-write makes the ceiling true rather than likely.
|
| 508 |
+
# β IT RECORDS THE REFUSAL RATHER THAN RAISING FROM INSIDE `_up`: an exception thrown through
|
| 509 |
+
# `store.update` would surface as this route's own 503 "the store refused the write", which is a
|
| 510 |
+
# different and wrong story about a limit the caller can act on.
|
| 511 |
+
refused = {"full": False}
|
| 512 |
+
|
| 513 |
+
def _up(data):
|
| 514 |
+
data = data if isinstance(data, dict) else {}
|
| 515 |
+
mine = dict(data.get(uname) or {}) if isinstance(data.get(uname), dict) else {}
|
| 516 |
+
ids = _clean_list(mine.get(field))
|
| 517 |
+
if on:
|
| 518 |
+
if oid not in ids:
|
| 519 |
+
if len(ids) >= MAX_PER_KIND:
|
| 520 |
+
refused["full"] = True
|
| 521 |
+
return data
|
| 522 |
+
ids.append(oid)
|
| 523 |
+
else:
|
| 524 |
+
ids = [i for i in ids if i != oid]
|
| 525 |
+
if ids:
|
| 526 |
+
mine[field] = ids
|
| 527 |
+
else:
|
| 528 |
+
mine.pop(field, None)
|
| 529 |
+
# A user with nothing starred is REMOVED rather than stored empty β a bucket that
|
| 530 |
+
# accumulates `{}` per account is a document that grows forever and says nothing.
|
| 531 |
+
if mine:
|
| 532 |
+
data[uname] = mine
|
| 533 |
+
else:
|
| 534 |
+
data.pop(uname, None)
|
| 535 |
+
return data
|
| 536 |
+
|
| 537 |
+
try:
|
| 538 |
+
session.runtime.update(_STARRED_KEY, _up)
|
| 539 |
+
except Exception:
|
| 540 |
+
raise err(503, "store_unavailable",
|
| 541 |
+
"the star was not saved: the store refused the write.")
|
| 542 |
+
if refused["full"]:
|
| 543 |
+
# R6's second sentence: the cause and the action, never a 200 over a star that was dropped.
|
| 544 |
+
raise err(400, "starred_full",
|
| 545 |
+
f"you have starred the maximum of {MAX_PER_KIND} items of this kind. "
|
| 546 |
+
f"Unstar one to make room")
|
| 547 |
+
return _payload(session)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ STARRED COUNTS (R7, C3)
|
| 551 |
+
#
|
| 552 |
+
# β R7: *"Row counts come from a NEW on-demand endpoint called after Home/Starred paint, never from
|
| 553 |
+
# `/nav`. A view whose count is not free shows the CAUSE, never an invented number."*
|
| 554 |
+
#
|
| 555 |
+
# β SO EVERY VID LANDS IN EXACTLY ONE OF `counts` AND `notes`, AND THAT IS THE CONTRACT (C3). A vid
|
| 556 |
+
# in neither is a client rendering nothing with no idea why; a vid in both is two answers to one
|
| 557 |
+
# question. `notes` is not a fallback for "we did not get round to it" β it is the CAUSE, in a
|
| 558 |
+
# sentence, with what to do about it, which is R6's second sentence applied to a badge.
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def _cohort_sizes(session, key, cache):
|
| 562 |
+
"""`{cohort_id: size}` for THIS caller's own cohorts on `key`'s topic. Memoised per request.
|
| 563 |
+
|
| 564 |
+
β THIS CALLER'S OWN COHORTS ONLY, and the consequence is deliberate: a SHARED view locked to a
|
| 565 |
+
cohort somebody else owns finds no id here, so it is reported UNCOUNTED with that cause rather
|
| 566 |
+
than counted out of a stratum this session cannot see. Widening the read would make the number
|
| 567 |
+
disclose the SIZE of another person's private list, which is a leak wearing a bug fix.
|
| 568 |
+
β Read through `session.runtime`, NOT `modules.cohort`'s module-level helpers: those call
|
| 569 |
+
`core.store` directly and carry no tenant namespace. The MODULE is asked for the bucket NAME (it
|
| 570 |
+
owns that rule) and this route does the reading, which is the only tenant-correct combination.
|
| 571 |
+
"""
|
| 572 |
+
import core.view_templates as view_templates
|
| 573 |
+
import modules.cohort as cohort_mod
|
| 574 |
+
ws_key = view_templates.workspace_key(key) or ""
|
| 575 |
+
suffix = "_table_workspace"
|
| 576 |
+
scope = ws_key[:-len(suffix)] if ws_key.endswith(suffix) else key
|
| 577 |
+
if scope in cache:
|
| 578 |
+
return cache[scope]
|
| 579 |
+
try:
|
| 580 |
+
bucket = session.runtime.get(cohort_mod.key_for(scope)) or {}
|
| 581 |
+
mine = bucket.get(session.uname) or {}
|
| 582 |
+
cache[scope] = {str(cid): len(c.get("members") or [])
|
| 583 |
+
for cid, c in mine.items() if isinstance(c, dict)}
|
| 584 |
+
except Exception:
|
| 585 |
+
cache[scope] = {}
|
| 586 |
+
return cache[scope]
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def _count_note(cfg, cohorts):
|
| 590 |
+
"""WHY this view's size is not free, as one sentence naming a cause AND a next step.
|
| 591 |
+
|
| 592 |
+
β R6's SECOND SENTENCE IS THE SPEC HERE, and a bare dash is the thing it forbids. D-253 was
|
| 593 |
+
booked once already for a limit whose explanation was hover-only, so this has to be a real
|
| 594 |
+
sentence a client can put in a `title` AND an `aria-label`.
|
| 595 |
+
β THREE DIFFERENT CAUSES, not one generic apology. A view locked to somebody else's list, a
|
| 596 |
+
filtered view and a whole-table view are three different facts, and only one of them is
|
| 597 |
+
something the reader can act on.
|
| 598 |
+
"""
|
| 599 |
+
cfg = cfg if isinstance(cfg, dict) else {}
|
| 600 |
+
lock = str(cfg.get("cohortLock") or "").strip()
|
| 601 |
+
if lock:
|
| 602 |
+
return ("This view is locked to a list owned by somebody else, so its size is not "
|
| 603 |
+
"something your account can read. Ask whoever shared it.")
|
| 604 |
+
if cfg.get("filters"):
|
| 605 |
+
return ("Counting a filtered view means reading every record, which this page does not "
|
| 606 |
+
"do. Open the database and the number is beside the view.")
|
| 607 |
+
return ("This view covers the whole database, so its size is the record count. Open the "
|
| 608 |
+
"database to see it.")
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
@router.get("/starred/counts")
|
| 612 |
+
def starred_counts(session: Session = Depends(require_session)):
|
| 613 |
+
"""C3 β `{"counts": {vid: int}, "notes": {vid: str}, "degraded": [key]}`.
|
| 614 |
+
|
| 615 |
+
β CALLED AFTER THE PAGE PAINTS, NEVER FROM `/nav` (R7). `/nav` is a rows-free projection whose
|
| 616 |
+
budget was the subject of D-175, D-185, D-288 and D-289; W35-T43 takes the last of that off it,
|
| 617 |
+
and putting a counting pass back on the nav's path would undo the whole point.
|
| 618 |
+
|
| 619 |
+
β THE COUNTER IS `routes_nav._view_record_count`, CALLED β not a second implementation. It is the
|
| 620 |
+
one function that knows which shapes are free (a cohort lock resolves to a stored member list; a
|
| 621 |
+
curated `memberPids` carries its own length) and returns None for everything else. A second
|
| 622 |
+
counter here would be free to disagree with the badge inside the database.
|
| 623 |
+
"""
|
| 624 |
+
rows, unread = _scan_starred_views(session)
|
| 625 |
+
cache, counts, notes = {}, {}, {}
|
| 626 |
+
for row in rows:
|
| 627 |
+
cfg, key, vid = row["config"], row["database"], row["id"]
|
| 628 |
+
sizes = _cohort_sizes(session, key, cache)
|
| 629 |
+
n = routes_nav._view_record_count(cfg, sizes)
|
| 630 |
+
# β EXACTLY ONE OF THE TWO MAPS (C3). The `if/else` is what makes that structural rather
|
| 631 |
+
# than a rule somebody has to remember: there is no path that writes both and none that
|
| 632 |
+
# writes neither.
|
| 633 |
+
if isinstance(n, int):
|
| 634 |
+
counts[vid] = n
|
| 635 |
+
else:
|
| 636 |
+
notes[vid] = _count_note(cfg, sizes)
|
| 637 |
+
return {"counts": counts, "notes": notes, "degraded": unread}
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ RECORD STARS (R5/R6, C5)
|
| 641 |
+
#
|
| 642 |
+
# β R5: *"Records get their own star, feeding an undeletable 'Starred records' view beside
|
| 643 |
+
# 'All records' on every database."* R6: *"Record stars are PER USER, in the same per-username
|
| 644 |
+
# stratum views already live in."*
|
| 645 |
+
#
|
| 646 |
+
# ββ THE ID LIST IS ITS OWN KEY IN THAT STRATUM AND IT DOES NOT TRAVEL THROUGH THE VIEW DOOR.
|
| 647 |
+
# D-170, measured: on a read-through grid a VIEW WRITE answers **409 `window_required`** β
|
| 648 |
+
# `/grid/events` -> `routes_tables.ut_write_ctx` -> `scoped_pids` -> `scoped_pool` ->
|
| 649 |
+
# `routes_odoo_tables.whole_pool` raises `TooBigToMaterialise`, because the shared read/write
|
| 650 |
+
# assembly insists on the pid set. `ut_odoo_order_lines` (255,286 rows) and `ut_odoo_gl_lines`
|
| 651 |
+
# (971,034) serve rows through a window correctly and refuse every view-config write. A record star
|
| 652 |
+
# stored inside a view record would therefore be IMPOSSIBLE on exactly the two biggest databases in
|
| 653 |
+
# the tenant. This writes one key in the workspace bucket and touches no pid set, so it answers 200
|
| 654 |
+
# there.
|
| 655 |
+
#
|
| 656 |
+
# β AND FOR THE SAME REASON IT DOES NOT VALIDATE THE ID AGAINST THE ROW SET. Checking that a pid
|
| 657 |
+
# exists means materialising the pool, which is the 409 again. `/nav/opened` takes the identical
|
| 658 |
+
# posture and says why: the WRITE is cheap and unvalidated, the READ is what prunes β a client
|
| 659 |
+
# intersects `memberPids` with the rows it can see, exactly as a cohort already does
|
| 660 |
+
# (`aios_grid.workspace_wire` drops absent members and reports `missing`).
|
| 661 |
+
|
| 662 |
+
#: The per-user key inside `<database>_table_workspace`. BESIDE `views`, never inside one (R6).
|
| 663 |
+
_RECORDS_KEY = "starredRecords"
|
| 664 |
+
|
| 665 |
+
#: The pinned id of the projected view. β C's rail pins this too (W35-T30) β it is a WIRE constant,
|
| 666 |
+
#: so it is exported rather than spelled twice.
|
| 667 |
+
STARRED_VIEW_ID = "starred-records"
|
| 668 |
+
STARRED_VIEW_NAME = "Starred records"
|
| 669 |
+
|
| 670 |
+
#: A bound per (user, database). Reported on overflow, never silently enforced.
|
| 671 |
+
MAX_STARRED_RECORDS = 500
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def _record_ids(doc, uname):
|
| 675 |
+
"""This caller's starred record ids on ONE database, validated on the way out.
|
| 676 |
+
|
| 677 |
+
β INTS, AND A NON-DIGIT ID IS NOT STORED. Every record identity in this product is a digit
|
| 678 |
+
string β a pool `pid` is an int and a `ut_*` row key is `str(record_id).isdigit()` β and the
|
| 679 |
+
CLIENT's view engine reads `memberPids` as numbers (a cohort's members are ints). A mixed list
|
| 680 |
+
would make the projection match nothing on the rows it was built for, silently.
|
| 681 |
+
"""
|
| 682 |
+
raw = (doc.get(uname) or {}).get(_RECORDS_KEY) if isinstance(doc.get(uname), dict) else None
|
| 683 |
+
if not isinstance(raw, list):
|
| 684 |
+
return []
|
| 685 |
+
out, seen = [], set()
|
| 686 |
+
for item in raw[:MAX_STARRED_RECORDS]:
|
| 687 |
+
try:
|
| 688 |
+
pid = int(item)
|
| 689 |
+
except (TypeError, ValueError):
|
| 690 |
+
continue
|
| 691 |
+
if pid not in seen:
|
| 692 |
+
seen.add(pid)
|
| 693 |
+
out.append(pid)
|
| 694 |
+
return out
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def records_view(ids):
|
| 698 |
+
"""The **"Starred records"** view, as a SavedView β or None when nothing is starred.
|
| 699 |
+
|
| 700 |
+
β THE SERVER HANDS C THE WHOLE OBJECT (C5) rather than a bare id list, so the rail cannot grow
|
| 701 |
+
a second idea of what this view is. `kind: 'system'` and `locked: True` are what make it
|
| 702 |
+
undeletable, the same two facts that protect `all-customers`.
|
| 703 |
+
|
| 704 |
+
β `memberPids` IS THE WHOLE MECHANISM: it makes this a CURATED ROW SET, which is the one shape
|
| 705 |
+
`routes_nav._view_record_count` can count for FREE β no rows read, no filter run. That is why C3
|
| 706 |
+
can put a real number beside it and why R7's counts endpoint costs nothing here.
|
| 707 |
+
β None WHEN EMPTY, not an empty view: a view listing zero records under a name promising some is
|
| 708 |
+
the shape D-229 was booked for. C shows the row only when there is something in it.
|
| 709 |
+
"""
|
| 710 |
+
pids = [int(p) for p in (ids or [])]
|
| 711 |
+
if not pids:
|
| 712 |
+
return None
|
| 713 |
+
return {
|
| 714 |
+
"id": STARRED_VIEW_ID,
|
| 715 |
+
"name": STARRED_VIEW_NAME,
|
| 716 |
+
"kind": "system",
|
| 717 |
+
"locked": True,
|
| 718 |
+
"note": "The records you starred on this database. Only you can see this list.",
|
| 719 |
+
"config": {"filters": [], "filterConj": "and", "sorts": [], "groupBy": None,
|
| 720 |
+
"colorBy": None, "rowHeightMode": "short", "order": [], "visible": [],
|
| 721 |
+
"widths": {}, "memberPids": pids},
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def _database_or_refuse(session, key):
|
| 726 |
+
"""The workspace bucket for a database this caller may OPEN, or a refusal.
|
| 727 |
+
|
| 728 |
+
β THE SPLIT IS `routes_nav.nav_schema`'s, COPIED IN SHAPE RATHER THAN INVENTED: a `ut_*`
|
| 729 |
+
database is walled by `user_tables.may_open` (its creator or an admin or a grantee), and a
|
| 730 |
+
built-in module by `session.require`, which would 403 every `ut_` key because a user table is
|
| 731 |
+
deliberately not a module.
|
| 732 |
+
"""
|
| 733 |
+
import core.view_templates as view_templates
|
| 734 |
+
ws_key = view_templates.workspace_key(key)
|
| 735 |
+
if not ws_key:
|
| 736 |
+
raise err(404, "unknown_database", "that database is not available")
|
| 737 |
+
if key.startswith("ut_"):
|
| 738 |
+
import core.user_tables as user_tables
|
| 739 |
+
if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
|
| 740 |
+
raise err(403, "forbidden", "that database belongs to another user")
|
| 741 |
+
else:
|
| 742 |
+
session.require(key)
|
| 743 |
+
return ws_key
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _records_payload(session, key, ws_key):
|
| 747 |
+
try:
|
| 748 |
+
doc = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
|
| 749 |
+
except Exception:
|
| 750 |
+
doc = {}
|
| 751 |
+
ids = _record_ids(doc, session.uname)
|
| 752 |
+
return {"database": key, "ids": ids, "count": len(ids), "view": records_view(ids)}
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
@router.get("/starred/records")
|
| 756 |
+
def starred_records(database: str = "", session: Session = Depends(require_session)):
|
| 757 |
+
"""C5 β the record ids THIS caller starred on ONE database, plus the view to render.
|
| 758 |
+
|
| 759 |
+
β ONE DATABASE PER CALL, deliberately. Answering "every database" would be the N-bucket scan
|
| 760 |
+
the view half already pays for, on a route a grid calls every time it opens.
|
| 761 |
+
"""
|
| 762 |
+
key = _clean_id(database)
|
| 763 |
+
if not key:
|
| 764 |
+
raise err(400, "bad_request", "no database was named")
|
| 765 |
+
return _records_payload(session, key, _database_or_refuse(session, key))
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
@router.post("/starred/records")
|
| 769 |
+
def set_starred_record(body: dict = Body(default=None),
|
| 770 |
+
session: Session = Depends(require_session)):
|
| 771 |
+
"""C5 β star or unstar ONE record. Body `{"database": key, "id": str, "on": bool}`.
|
| 772 |
+
|
| 773 |
+
β PER USER, IN THE PER-USERNAME STRATUM, AND IT MUST NEVER FALL BACK TO A TENANT-WIDE LIST
|
| 774 |
+
(R6). One person's stars becoming everyone's is the failure mode a shared column has here β a
|
| 775 |
+
custom field on these grids is written through a per-username stratum anyway, so a "shared"
|
| 776 |
+
answer would be a deliberate widening, not a shortcut.
|
| 777 |
+
"""
|
| 778 |
+
body = body if isinstance(body, dict) else {}
|
| 779 |
+
key = _clean_id(body.get("database"))
|
| 780 |
+
raw_id = _clean_id(body.get("id"))
|
| 781 |
+
if not key:
|
| 782 |
+
raise err(400, "bad_request", "no database was named")
|
| 783 |
+
if not raw_id.isdigit():
|
| 784 |
+
# Named rather than coerced: see `_record_ids` on why a non-numeric id cannot be stored.
|
| 785 |
+
raise err(400, "bad_request", "a record id is a number")
|
| 786 |
+
if "on" not in body or not isinstance(body.get("on"), bool):
|
| 787 |
+
raise err(400, "bad_request", "`on` must be true or false")
|
| 788 |
+
pid, on = int(raw_id), bool(body["on"])
|
| 789 |
+
ws_key = _database_or_refuse(session, key)
|
| 790 |
+
if not session.runtime.available():
|
| 791 |
+
raise err(503, "store_unavailable",
|
| 792 |
+
"the tenant store is unavailable. Nothing was saved.")
|
| 793 |
+
current = _records_payload(session, key, ws_key)["ids"]
|
| 794 |
+
if on and pid not in current and len(current) >= MAX_STARRED_RECORDS:
|
| 795 |
+
raise err(400, "starred_full",
|
| 796 |
+
f"you have starred the maximum of {MAX_STARRED_RECORDS} records on this "
|
| 797 |
+
f"database. Unstar one to make room")
|
| 798 |
+
uname = session.uname
|
| 799 |
+
|
| 800 |
+
def _up(data):
|
| 801 |
+
data = data if isinstance(data, dict) else {}
|
| 802 |
+
block = data.get(uname)
|
| 803 |
+
block = dict(block) if isinstance(block, dict) else {}
|
| 804 |
+
ids = _record_ids({uname: block}, uname)
|
| 805 |
+
if on:
|
| 806 |
+
if pid not in ids:
|
| 807 |
+
ids.append(pid)
|
| 808 |
+
else:
|
| 809 |
+
ids = [i for i in ids if i != pid]
|
| 810 |
+
if ids:
|
| 811 |
+
block[_RECORDS_KEY] = ids
|
| 812 |
+
else:
|
| 813 |
+
block.pop(_RECORDS_KEY, None)
|
| 814 |
+
# β THE STRATUM IS KEPT WHEN IT STILL HOLDS ANYTHING ELSE. Popping a username whose views
|
| 815 |
+
# and overlays live in the same block would delete somebody's whole workspace to clear one
|
| 816 |
+
# star β which is why this writes `block` back rather than replacing it.
|
| 817 |
+
if block:
|
| 818 |
+
data[uname] = block
|
| 819 |
+
else:
|
| 820 |
+
data.pop(uname, None)
|
| 821 |
+
return data
|
| 822 |
+
|
| 823 |
+
try:
|
| 824 |
+
session.runtime.update(ws_key, _up)
|
| 825 |
+
except Exception:
|
| 826 |
+
raise err(503, "store_unavailable",
|
| 827 |
+
"the star was not saved: the store refused the write.")
|
| 828 |
+
return _records_payload(session, key, ws_key)
|
api/routes_statements.py
CHANGED
|
@@ -31,6 +31,10 @@ from fastapi import APIRouter, Body, Depends
|
|
| 31 |
from deps import Session, err
|
| 32 |
from routes_admin import admin_gate
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
router = APIRouter(prefix="/api/v1")
|
| 35 |
|
| 36 |
#: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
|
|
@@ -48,8 +52,17 @@ def _royal_only(session: Session) -> Session:
|
|
| 48 |
"""β See the module docstring, guardrail 3. The send client is env-credentialed, so it is
|
| 49 |
tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
|
| 50 |
|
| 51 |
-
Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
raise err(404, "not_found", "statements are not configured for this workspace")
|
| 54 |
return session
|
| 55 |
|
|
@@ -91,7 +104,9 @@ def statements(refresh: int = 0, session: Session = Depends(_gate)):
|
|
| 91 |
"replyTo": cs.REPLY_TO, "company": cs.COMPANY},
|
| 92 |
"templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
|
| 93 |
"footer": cs.DEFAULT_FOOTER},
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
|
|
@@ -166,3 +181,89 @@ def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
|
|
| 166 |
failed.append({"customer": name, "error": str(e)[:200]})
|
| 167 |
return {"sent": sent, "failed": failed, "skipped": skipped,
|
| 168 |
"safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from deps import Session, err
|
| 32 |
from routes_admin import admin_gate
|
| 33 |
|
| 34 |
+
# W35-T35 (C8): the tenant predicate, held once. See `_royal_only` for why the import runs this
|
| 35 |
+
# way round. `main.py` already imports both modules, so this adds no load.
|
| 36 |
+
import automation_engine as engine
|
| 37 |
+
|
| 38 |
router = APIRouter(prefix="/api/v1")
|
| 39 |
|
| 40 |
#: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
|
|
|
|
| 52 |
"""β See the module docstring, guardrail 3. The send client is env-credentialed, so it is
|
| 53 |
tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
|
| 54 |
|
| 55 |
+
Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.
|
| 56 |
+
|
| 57 |
+
ββ WAVE 35 Β· T35 / CONTRACT C8 β THE TEST ITSELF NOW LIVES IN ONE PLACE. Statements became an
|
| 58 |
+
agent step this wave, so the automation engine has to answer the same question at two more
|
| 59 |
+
doors (which tenants see the action, which tenants may store it). C8's words are "the predicate
|
| 60 |
+
is imported, never re-expressed", and this is the direction that import can run: the engine
|
| 61 |
+
imports no route module and no FastAPI, so reaching `_royal_only` FROM it would drag `deps` and
|
| 62 |
+
`routes_admin` into a route-free module. The engine holds the boolean; this holds the refusal.
|
| 63 |
+
β WHAT STAYS HERE IS THE HTTP SHAPE, and that is deliberate β a 404 rather than a 403, so the
|
| 64 |
+
surface is invisible rather than forbidden. The engine must not know about status codes."""
|
| 65 |
+
if not engine.is_statement_tenant(session.runtime):
|
| 66 |
raise err(404, "not_found", "statements are not configured for this workspace")
|
| 67 |
return session
|
| 68 |
|
|
|
|
| 104 |
"replyTo": cs.REPLY_TO, "company": cs.COMPANY},
|
| 105 |
"templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
|
| 106 |
"footer": cs.DEFAULT_FOOTER},
|
| 107 |
+
# W35-T35: ONE literal, read from the engine, so the sender's worklist and a statements
|
| 108 |
+
# agent's tier filter cannot come to mean different things.
|
| 109 |
+
"tiers": list(engine.STATEMENT_TIERS),
|
| 110 |
}
|
| 111 |
|
| 112 |
|
|
|
|
| 181 |
failed.append({"customer": name, "error": str(e)[:200]})
|
| 182 |
return {"sent": sent, "failed": failed, "skipped": skipped,
|
| 183 |
"safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ---------------------------------------------------------------------------------------------
|
| 187 |
+
# WAVE 35 Β· T36 / CONTRACT C8 / OWNER RULING R10 β THE AGENT'S PARKED BATCH.
|
| 188 |
+
#
|
| 189 |
+
# ββ THE AGENT ASSEMBLES; A PERSON CLICKS SEND. `automation_engine.run_statements` renders a batch
|
| 190 |
+
# and parks it on the automation definition, and it imports no mail path at all. THIS is the only
|
| 191 |
+
# door that releases one, and it is deliberately built out of the parts already here:
|
| 192 |
+
# Β· `_gate` β `admin_gate` THEN `_royal_only`, byte-for-byte the dependency the other three
|
| 193 |
+
# endpoints use. Not a copy of the checks: the same object.
|
| 194 |
+
# Β· `cs.queue_statement` β the same call `send()` above makes, so SAFE_MODE's allow-list refusal
|
| 195 |
+
# happens in the DATA LAYER, where no route, payload or UI can reach around it.
|
| 196 |
+
# β Nothing about the guardrails is re-expressed here, which is what makes "unchanged" checkable.
|
| 197 |
+
#
|
| 198 |
+
# β WHY THE BATCH IS RE-RENDERED FROM THE LIVE WORKLIST RATHER THAN SENT AS STORED. The parked
|
| 199 |
+
# `html` is what a person APPROVED and is what the review screen shows; but a balance can move
|
| 200 |
+
# between the parking and the click, and mailing a figure we know to be stale is worse than mailing
|
| 201 |
+
# a fresh one. So the parked batch decides WHO and WITH WHAT WORDS, and the live row decides the
|
| 202 |
+
# NUMBERS β the same split `preview` already makes. A customer who has left the worklist entirely
|
| 203 |
+
# (they paid) is reported as skipped rather than invoiced.
|
| 204 |
+
|
| 205 |
+
@router.get("/admin/statements/agent/{auto_id}")
|
| 206 |
+
def agent_batch(auto_id: str, session: Session = Depends(_gate)):
|
| 207 |
+
"""What is parked and waiting for a click, for ONE agent. `null` when nothing is."""
|
| 208 |
+
defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
|
| 209 |
+
pending = defn.get("pendingStatements")
|
| 210 |
+
if not isinstance(pending, dict):
|
| 211 |
+
return {"pending": None}
|
| 212 |
+
# β The rendered `html` is NOT returned in the list β a 200-statement batch of rendered mail is
|
| 213 |
+
# megabytes, and the review screen needs the count and the names to decide. `preview` already
|
| 214 |
+
# renders ONE on demand.
|
| 215 |
+
return {"pending": {
|
| 216 |
+
"ts": pending.get("ts"), "count": int(pending.get("count") or 0),
|
| 217 |
+
"notes": list(pending.get("notes") or []),
|
| 218 |
+
"items": [{k: v for k, v in it.items() if k != "html"}
|
| 219 |
+
for it in (pending.get("items") or [])],
|
| 220 |
+
"safeMode": bool(_cs().SAFE_MODE)}}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post("/admin/statements/agent/{auto_id}/send")
|
| 224 |
+
def agent_send(auto_id: str, body: dict = Body(default=None),
|
| 225 |
+
session: Session = Depends(_gate)):
|
| 226 |
+
"""Release a parked batch. THE CLICK R10 REQUIRES β nothing else in the system calls this.
|
| 227 |
+
|
| 228 |
+
β IT REFUSES AN EMPTY OR MISSING BATCH rather than answering 200 with nothing sent: a send
|
| 229 |
+
that reports success and mails nobody is the `view_upsert` failure mode (200 OK, zero writes)
|
| 230 |
+
arriving on the one route in this system that talks to a mail server.
|
| 231 |
+
"""
|
| 232 |
+
cs = _cs()
|
| 233 |
+
defn = (engine.all_definitions(session.runtime) or {}).get(str(auto_id)) or {}
|
| 234 |
+
pending = defn.get("pendingStatements")
|
| 235 |
+
items = list((pending or {}).get("items") or []) if isinstance(pending, dict) else []
|
| 236 |
+
if not items:
|
| 237 |
+
raise err(404, "no_batch", "there is nothing parked for this agent to send")
|
| 238 |
+
# β An explicit subset is allowed (a person unticking a customer on the review screen) but it
|
| 239 |
+
# may only NARROW the parked batch. A name that was never parked cannot be introduced by the
|
| 240 |
+
# payload, or the review stops being what authorised the send.
|
| 241 |
+
want = {str(n) for n in (body or {}).get("customers") or []}
|
| 242 |
+
if want:
|
| 243 |
+
items = [it for it in items if str(it.get("customer")) in want]
|
| 244 |
+
if not items:
|
| 245 |
+
raise err(400, "bad_request", "none of those customers are in the parked batch")
|
| 246 |
+
rows, _ = _rows()
|
| 247 |
+
sent, failed, skipped = [], [], []
|
| 248 |
+
for it in items:
|
| 249 |
+
name = str(it.get("customer") or "")
|
| 250 |
+
row = _find(rows, name)
|
| 251 |
+
if row is None:
|
| 252 |
+
skipped.append({"customer": name,
|
| 253 |
+
"reason": "no longer on the collection list, so nothing is owed"})
|
| 254 |
+
continue
|
| 255 |
+
try:
|
| 256 |
+
# β THE SAME DATA-LAYER CALL `send()` MAKES. SafeModeBlocked is raised INSIDE
|
| 257 |
+
# `queue_statement`, so the guardrail cannot be argued with from here.
|
| 258 |
+
mid = cs.queue_statement(cs.Odoo(), row,
|
| 259 |
+
str(it.get("subject") or "") or cs.DEFAULT_SUBJECT,
|
| 260 |
+
cs.DEFAULT_INTRO, cs.DEFAULT_FOOTER)
|
| 261 |
+
sent.append({"customer": name, "to": row.get("Email"), "mailId": mid})
|
| 262 |
+
except Exception as e: # noqa: BLE001
|
| 263 |
+
failed.append({"customer": name, "error": str(e)[:200]})
|
| 264 |
+
# β CLEARED ONLY WHEN NOTHING IS LEFT TO RETRY. A batch dropped while some of it failed would
|
| 265 |
+
# lose the list of who still needs a statement, and nobody would know to look.
|
| 266 |
+
if sent and not failed:
|
| 267 |
+
engine.clear_statements(session.runtime, auto_id)
|
| 268 |
+
return {"sent": sent, "failed": failed, "skipped": skipped,
|
| 269 |
+
"safeMode": bool(cs.SAFE_MODE), "cleared": bool(sent and not failed)}
|
api/routes_tables.py
CHANGED
|
@@ -1406,7 +1406,11 @@ def _fire_on_change(table_key, pid, changed, session):
|
|
| 1406 |
defn = _ut().get(table_key, st=session.runtime) or {}
|
| 1407 |
wanted = _ae.on_change_fields(defn, changed.keys())
|
| 1408 |
for field in wanted:
|
| 1409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1410 |
except Exception: # noqa: BLE001
|
| 1411 |
pass
|
| 1412 |
|
|
@@ -1450,7 +1454,9 @@ def enrich_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
|
| 1450 |
report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
|
| 1451 |
policy=str((body or {}).get("scope") or "") or None,
|
| 1452 |
# A named row set through THIS door is a person asking.
|
| 1453 |
-
manual=rows is not None
|
|
|
|
|
|
|
| 1454 |
if report.get("problem"):
|
| 1455 |
# A run that could not start at all is not a 200: nothing was attempted, nothing was
|
| 1456 |
# spent, and the reason is actionable (no provider configured, or the wrong column).
|
|
|
|
| 1406 |
defn = _ut().get(table_key, st=session.runtime) or {}
|
| 1407 |
wanted = _ae.on_change_fields(defn, changed.keys())
|
| 1408 |
for field in wanted:
|
| 1409 |
+
# β W35-T41 / C7 β `user` is the usage ledger's attribution. An on-change run is still
|
| 1410 |
+
# somebody's edit spending somebody's tokens, so it is booked against the person who
|
| 1411 |
+
# typed rather than left unattributed.
|
| 1412 |
+
_ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)],
|
| 1413 |
+
user=session.uname)
|
| 1414 |
except Exception: # noqa: BLE001
|
| 1415 |
pass
|
| 1416 |
|
|
|
|
| 1454 |
report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
|
| 1455 |
policy=str((body or {}).get("scope") or "") or None,
|
| 1456 |
# A named row set through THIS door is a person asking.
|
| 1457 |
+
manual=rows is not None,
|
| 1458 |
+
# β W35-T41 / C7 β the usage ledger's attribution.
|
| 1459 |
+
user=session.uname)
|
| 1460 |
if report.get("problem"):
|
| 1461 |
# A run that could not start at all is not a 200: nothing was attempted, nothing was
|
| 1462 |
# spent, and the reason is actionable (no provider configured, or the wrong column).
|
api/routes_usage.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_usage.py β WAVE 35 (ruling R9, contract C7): `GET /api/v1/usage`, the AI meter.
|
| 2 |
+
|
| 3 |
+
Owner item 13's second door. R9: the dashboard REPORTS weekly usage per surface against an
|
| 4 |
+
allowance and does **not** cut anyone off this wave; over the allowance the product still works and
|
| 5 |
+
the bar is red.
|
| 6 |
+
|
| 7 |
+
β THIS ROUTE ENFORCES NOTHING AND MUST NOT START. It is the only reader of `usage_ledger`, and the
|
| 8 |
+
ledger has no ceiling in it (see that module's header). A `403 over_allowance` here would turn a
|
| 9 |
+
reporting feature into a wall the owner explicitly did not ask for this wave.
|
| 10 |
+
|
| 11 |
+
β TWO SCOPES, AND THE WIDER ONE IS ADMIN-ONLY. `?scope=me` (the default) is the caller's own week;
|
| 12 |
+
`?scope=tenant` folds every account and is refused for a non-admin β how much AI another employee
|
| 13 |
+
used is not a fact this product hands out sideways.
|
| 14 |
+
"""
|
| 15 |
+
from fastapi import APIRouter, Depends
|
| 16 |
+
|
| 17 |
+
import usage_ledger
|
| 18 |
+
from deps import Session, err, require_session
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v1")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/usage")
|
| 24 |
+
def usage(scope: str = "me", session: Session = Depends(require_session)):
|
| 25 |
+
"""`{week, resets, allowance, total, over, calls, unmeasured, scope, surfaces: [...]}`.
|
| 26 |
+
|
| 27 |
+
Every one of the four surfaces is present whether or not it was used β see
|
| 28 |
+
`usage_ledger.usage`'s note on why a missing row and a quiet week must not look the same.
|
| 29 |
+
"""
|
| 30 |
+
wanted = str(scope or "me").strip().lower()
|
| 31 |
+
if wanted not in ("me", "tenant"):
|
| 32 |
+
raise err(400, "bad_request", "scope must be me or tenant")
|
| 33 |
+
if wanted == "tenant" and not session.admin:
|
| 34 |
+
raise err(403, "forbidden",
|
| 35 |
+
"only an administrator can see the whole workspace's AI usage")
|
| 36 |
+
out = usage_ledger.usage(session.runtime, session.uname,
|
| 37 |
+
tenant_wide=(wanted == "tenant"))
|
| 38 |
+
if session.admin and usage_ledger.UNATTRIBUTED["calls"]:
|
| 39 |
+
# β THE METER REPORTS ITS OWN BLIND SPOT (R6's second sentence). A call `record()` could not
|
| 40 |
+
# attribute β no store handle reached it, or the store refused β is absent from every number
|
| 41 |
+
# above, and an operator has to be able to see that rather than infer it.
|
| 42 |
+
# β ADMIN ONLY, and labelled as a PROCESS figure rather than a tenant one: the counter is a
|
| 43 |
+
# module global in this container, so it spans every tenant this process has served and is
|
| 44 |
+
# not a fact about the workspace being asked about.
|
| 45 |
+
out["unattributed"] = {
|
| 46 |
+
"calls": usage_ledger.UNATTRIBUTED["calls"],
|
| 47 |
+
"surfaces": dict(usage_ledger.UNATTRIBUTED["surfaces"]),
|
| 48 |
+
"note": ("these AI calls reached no ledger target in this container and are missing "
|
| 49 |
+
"from every figure above. It is a wiring gap, not usage"),
|
| 50 |
+
}
|
| 51 |
+
return out
|
api/usage_ledger.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""usage_ledger.py β WAVE 35 (ruling R9, contract C7): ONE METER FOR EVERY AI SURFACE.
|
| 2 |
+
|
| 3 |
+
R9: *"ONE meter for every AI surface. The dashboard REPORTS weekly usage against an allowance and
|
| 4 |
+
does NOT cut anyone off this wave; over the allowance it still works, shown red."*
|
| 5 |
+
|
| 6 |
+
β SO THIS FILE MEASURES AND NEVER ENFORCES. There is no ceiling here, no refusal, and no caller of
|
| 7 |
+
this module may branch on its answer. The one limit the product does enforce is per COLUMN and lives
|
| 8 |
+
where it was built (`ai_enrich.ceiling_report`); this is the tenant-wide REPORT, and mixing the two
|
| 9 |
+
would make an over-allowance week silently stop somebody's enrichment run.
|
| 10 |
+
|
| 11 |
+
**THE FOUR SURFACES.** `assistant` (a person asking the Assistant a question) Β· `field_agent` (an AI
|
| 12 |
+
enrichment column filling cells) Β· `ai_review` (a model deciding a review stage) Β· `automation_draft`
|
| 13 |
+
(a sentence becoming a draft flow). They are the four LLM entry points the product has, and the gate
|
| 14 |
+
in `verify_api.py` DERIVES them from the code rather than reading this list, so a fifth entry point
|
| 15 |
+
is caught by the gate and not by somebody remembering to edit a tuple.
|
| 16 |
+
|
| 17 |
+
ββ **`record()` MUST BE HANDED ITS TARGET, AND THAT IS A MEASUREMENT, NOT A STYLE CHOICE.** The
|
| 18 |
+
obvious design is to bind `(runtime, username)` into a `contextvars.ContextVar` inside
|
| 19 |
+
`deps.require_session` β every session-authed request passes through it β so `record()` could take
|
| 20 |
+
C7's five arguments and resolve the rest itself. **It does not work here and it fails SILENTLY.**
|
| 21 |
+
Measured 2026-08-17 with a FastAPI `TestClient`: a `ContextVar` set inside a sync dependency reads
|
| 22 |
+
back `None` in the handler *and in every function the handler calls*, on the SAME OS thread (17820
|
| 23 |
+
both sides) β anyio copies the context per callable, so a `.set()` in the dependency's copy is
|
| 24 |
+
invisible to the endpoint's. An async endpoint reads `None` too. A ledger built that way would have
|
| 25 |
+
recorded nothing, answered no error, and shown a dashboard of zeros that looks exactly like a quiet
|
| 26 |
+
week ([[flag-shipped-without-its-writer]]).
|
| 27 |
+
|
| 28 |
+
β THEREFORE `st` AND `user` ARE KEYWORD ARGUMENTS AFTER C7'S FIVE, and a call that omits them is
|
| 29 |
+
COUNTED as unattributed and REPORTED rather than dropped β R6's second sentence applied to the meter
|
| 30 |
+
itself. A surface whose caller cannot reach a store handle is a gap somebody has to close; a meter
|
| 31 |
+
that hides the gap is worse than one that admits it.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import os
|
| 36 |
+
import time
|
| 37 |
+
|
| 38 |
+
#: The store bucket. Small by construction β counters only, never a per-call log: a log would grow
|
| 39 |
+
#: without bound on a route that runs per row, and nothing in R9 asks a question a log answers.
|
| 40 |
+
STORE_KEY = "ai_usage"
|
| 41 |
+
|
| 42 |
+
#: The four AI surfaces (C7). β A surface this list does not name is REFUSED rather than stored under
|
| 43 |
+
#: whatever string arrived, because a typo'd surface is a meter that reads low forever with a second
|
| 44 |
+
#: row nobody looks at.
|
| 45 |
+
SURFACES = ("assistant", "field_agent", "ai_review", "automation_draft")
|
| 46 |
+
|
| 47 |
+
#: The weekly allowance the dashboard measures against (R9). β IT IS A REPORTING LINE, NOT A GATE.
|
| 48 |
+
#: Env-tunable so a deployment can set a real number without a release.
|
| 49 |
+
WEEKLY_ALLOWANCE = int(os.environ.get("AIOS_AI_WEEKLY_TOKENS") or 1_000_000)
|
| 50 |
+
|
| 51 |
+
#: How many weeks stay in the bucket. A meter is about this week and the trend behind it; keeping
|
| 52 |
+
#: every week forever turns a counter document into an append-only log by another route.
|
| 53 |
+
MAX_WEEKS = 8
|
| 54 |
+
|
| 55 |
+
#: Calls this PROCESS could not attribute to a store and a user β see the header. Reported to an
|
| 56 |
+
#: admin by `GET /usage`, never silently zero.
|
| 57 |
+
UNATTRIBUTED = {"calls": 0, "surfaces": {}}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def week_of(when=None):
|
| 61 |
+
"""The ISO week this instant belongs to, as `"2026-W33"`.
|
| 62 |
+
|
| 63 |
+
β UTC, EXPLICITLY, and never a naive local stamp. `routes_nav` records the same correction for
|
| 64 |
+
its own stamps: a naive local time written on a UTC host and parsed by a non-UTC browser misfiles
|
| 65 |
+
into the wrong bucket with nothing to go red. `%G`/`%V` are the ISO year and ISO week, which is
|
| 66 |
+
the only pair that agrees with itself across a year boundary (`%Y-%W` does not).
|
| 67 |
+
"""
|
| 68 |
+
return time.strftime("%G-W%V", time.gmtime(when if when is not None else time.time()))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def resets_at(when=None):
|
| 72 |
+
"""The UTC date the CURRENT week's counters roll over, as `"YYYY-MM-DD"`.
|
| 73 |
+
|
| 74 |
+
R9 asks the page to state when the allowance resets, so the server answers it β a client that
|
| 75 |
+
computed "next Monday" itself would be a second implementation of the week boundary, and the two
|
| 76 |
+
would disagree for anybody whose clock is not UTC ([[one-question-two-normalizers]]).
|
| 77 |
+
"""
|
| 78 |
+
now = when if when is not None else time.time()
|
| 79 |
+
tm = time.gmtime(now)
|
| 80 |
+
# `tm_wday` is 0 for Monday, so days-until-next-Monday is 7 minus however far in we are.
|
| 81 |
+
return time.strftime("%Y-%m-%d", time.gmtime(now + (7 - tm.tm_wday) * 86400))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def tokens_from(body):
|
| 85 |
+
"""`(tokens_in, tokens_out)` for one provider response. Either may be None.
|
| 86 |
+
|
| 87 |
+
β `None` MEANS THE PROVIDER DID NOT SAY, AND IT IS NOT ZERO. A ledger that books an unmeasured
|
| 88 |
+
call at zero reports a cheaper week than happened, which is exactly the cost-surprise complaint
|
| 89 |
+
R13 cited when the first token accounting was built. `ai_enrich._usage_tokens` has said this
|
| 90 |
+
since wave 34; this is that reader SPLIT, because C7 asks for input and output separately.
|
| 91 |
+
|
| 92 |
+
β THE TOTAL-ONLY SHAPE IS REAL AND IS NOT A SPLIT. Some providers answer only `total_tokens`.
|
| 93 |
+
That is reported by `record(total=...)` rather than by inventing a split, because halving a
|
| 94 |
+
total would put two numbers on a dashboard that were never measured.
|
| 95 |
+
"""
|
| 96 |
+
usage = (body or {}).get("usage")
|
| 97 |
+
if not isinstance(usage, dict):
|
| 98 |
+
return (None, None)
|
| 99 |
+
|
| 100 |
+
def _int(*keys):
|
| 101 |
+
for key in keys:
|
| 102 |
+
got = usage.get(key)
|
| 103 |
+
if isinstance(got, int) and not isinstance(got, bool):
|
| 104 |
+
return got
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
return (_int("prompt_tokens", "input_tokens"),
|
| 108 |
+
_int("completion_tokens", "output_tokens"))
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def total_from(body):
|
| 112 |
+
"""One call's TOTAL tokens, or None β the shape a caller with no use for the split wants.
|
| 113 |
+
|
| 114 |
+
β Prefers the provider's own `total_tokens` over a sum, because a provider that reports both may
|
| 115 |
+
count cached or reasoning tokens in the total and in neither half.
|
| 116 |
+
"""
|
| 117 |
+
usage = (body or {}).get("usage")
|
| 118 |
+
if isinstance(usage, dict):
|
| 119 |
+
for key in ("total_tokens", "totalTokens"):
|
| 120 |
+
got = usage.get(key)
|
| 121 |
+
if isinstance(got, int) and not isinstance(got, bool):
|
| 122 |
+
return got
|
| 123 |
+
ins, outs = tokens_from(body)
|
| 124 |
+
if ins is None and outs is None:
|
| 125 |
+
return None
|
| 126 |
+
return (ins or 0) + (outs or 0)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _int_or_none(value):
|
| 130 |
+
if isinstance(value, int) and not isinstance(value, bool):
|
| 131 |
+
return max(0, value)
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def record(surface, provider, model, tokens_in=None, tokens_out=None, *,
|
| 136 |
+
total=None, calls=1, st=None, user=""):
|
| 137 |
+
"""Book ONE AI call. Returns True when the line landed durably, False when it could not.
|
| 138 |
+
|
| 139 |
+
C7's signature is the five positional parameters; everything after `*` is this module's own
|
| 140 |
+
extension and each one has a reason:
|
| 141 |
+
|
| 142 |
+
Β· `total` β for a caller that knows the total and not the split (`ai_enrich.run_field`
|
| 143 |
+
accumulates a per-run total through an `_ask` contract a gate injects against, and changing
|
| 144 |
+
that contract to carry a split would break the injected shape for no gain here).
|
| 145 |
+
Β· `calls` β so a RUN over many rows books one line with its real call count instead of one
|
| 146 |
+
store write per row.
|
| 147 |
+
Β· `st` / `user` β the target. Required in practice; see this module's header for the measured
|
| 148 |
+
reason a contextvar cannot supply them.
|
| 149 |
+
|
| 150 |
+
β IT NEVER RAISES. A meter that can break the feature it measures is worse than no meter: a
|
| 151 |
+
store blip during an enrichment run would abort the run and lose the cells.
|
| 152 |
+
β AND IT NEVER SILENTLY SUCCEEDS. A call it cannot attribute increments `UNATTRIBUTED`, which
|
| 153 |
+
`GET /usage` shows an admin.
|
| 154 |
+
"""
|
| 155 |
+
key = str(surface or "").strip()
|
| 156 |
+
if key not in SURFACES:
|
| 157 |
+
# Refused rather than stored: a surface nobody named is a row nobody reads, and the meter
|
| 158 |
+
# would read low forever with no sign of why.
|
| 159 |
+
return False
|
| 160 |
+
ins, outs = _int_or_none(tokens_in), _int_or_none(tokens_out)
|
| 161 |
+
tot = _int_or_none(total)
|
| 162 |
+
if tot is None and (ins is not None or outs is not None):
|
| 163 |
+
tot = (ins or 0) + (outs or 0)
|
| 164 |
+
n = max(0, int(calls) if isinstance(calls, int) and not isinstance(calls, bool) else 1)
|
| 165 |
+
if not n:
|
| 166 |
+
return False
|
| 167 |
+
uname = str(user or "").strip().lower()
|
| 168 |
+
if st is None:
|
| 169 |
+
UNATTRIBUTED["calls"] += n
|
| 170 |
+
UNATTRIBUTED["surfaces"][key] = UNATTRIBUTED["surfaces"].get(key, 0) + n
|
| 171 |
+
return False
|
| 172 |
+
week = week_of()
|
| 173 |
+
prov, mdl = str(provider or "")[:40], str(model or "")[:80]
|
| 174 |
+
|
| 175 |
+
def _up(data):
|
| 176 |
+
data = data if isinstance(data, dict) else {}
|
| 177 |
+
weeks = {w: v for w, v in data.items() if isinstance(v, dict)}
|
| 178 |
+
row = ((weeks.setdefault(week, {})
|
| 179 |
+
.setdefault(uname or "-", {}))
|
| 180 |
+
.setdefault(key, {}))
|
| 181 |
+
row["calls"] = int(row.get("calls") or 0) + n
|
| 182 |
+
if tot is None:
|
| 183 |
+
# β "the call happened, the tokens are unknown" IS A STATE, and it has to be
|
| 184 |
+
# representable or the meter quietly under-reports. The dashboard shows it as a count of
|
| 185 |
+
# unmeasured calls beside the token total.
|
| 186 |
+
row["unmeasured"] = int(row.get("unmeasured") or 0) + n
|
| 187 |
+
else:
|
| 188 |
+
row["tokens"] = int(row.get("tokens") or 0) + tot
|
| 189 |
+
if ins is not None:
|
| 190 |
+
row["tokens_in"] = int(row.get("tokens_in") or 0) + ins
|
| 191 |
+
if outs is not None:
|
| 192 |
+
row["tokens_out"] = int(row.get("tokens_out") or 0) + outs
|
| 193 |
+
if prov:
|
| 194 |
+
row["provider"] = prov
|
| 195 |
+
if mdl:
|
| 196 |
+
row["model"] = mdl
|
| 197 |
+
# Oldest weeks first to go. Sorting ISO week strings sorts chronologically by construction,
|
| 198 |
+
# which is the second reason `%G-W%V` is the format rather than a prettier one.
|
| 199 |
+
for stale in sorted(weeks)[:-MAX_WEEKS]:
|
| 200 |
+
weeks.pop(stale, None)
|
| 201 |
+
return weeks
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
# β `flush='async'` β the coalescing mode ([[store-async-flush]]). This is the hot path of
|
| 205 |
+
# every AI surface in the product (an enrichment run books one line per RUN, but the
|
| 206 |
+
# assistant books one per question), and a blocking hub upload per call would put a network
|
| 207 |
+
# round trip inside the answer a person is waiting for. The mutation applies to the
|
| 208 |
+
# in-process cache immediately, so `GET /usage` reads its own writes.
|
| 209 |
+
st.update(STORE_KEY, _up, flush="async")
|
| 210 |
+
return True
|
| 211 |
+
except Exception: # noqa: BLE001
|
| 212 |
+
UNATTRIBUTED["calls"] += n
|
| 213 |
+
UNATTRIBUTED["surfaces"][key] = UNATTRIBUTED["surfaces"].get(key, 0) + n
|
| 214 |
+
return False
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _blank_row():
|
| 218 |
+
return {"calls": 0, "tokens": 0, "tokens_in": 0, "tokens_out": 0, "unmeasured": 0}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _fold(rows):
|
| 222 |
+
out = _blank_row()
|
| 223 |
+
for row in rows:
|
| 224 |
+
if not isinstance(row, dict):
|
| 225 |
+
continue
|
| 226 |
+
for field in ("calls", "tokens", "tokens_in", "tokens_out", "unmeasured"):
|
| 227 |
+
got = row.get(field)
|
| 228 |
+
if isinstance(got, int) and not isinstance(got, bool):
|
| 229 |
+
out[field] += max(0, got)
|
| 230 |
+
return out
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def usage(st, user="", *, tenant_wide=False, week=None):
|
| 234 |
+
"""This week's meter. `{week, resets, allowance, total, over, surfaces: [...], ...}`.
|
| 235 |
+
|
| 236 |
+
`tenant_wide=True` folds every account in the tenant β the admin view. Otherwise only `user`.
|
| 237 |
+
|
| 238 |
+
β EVERY SURFACE IS PRESENT WHETHER OR NOT IT WAS USED, at zero. A surface a consumer has to test
|
| 239 |
+
for is a surface a consumer forgets to test for, and R9 says ONE meter for every AI surface: a
|
| 240 |
+
missing row and a quiet week would render identically, and only one of them is true.
|
| 241 |
+
β NOTHING HERE IS ESTIMATED. A surface with no ledger line reports zero, never a guess
|
| 242 |
+
([[no-unverifiable-aggregates]]).
|
| 243 |
+
"""
|
| 244 |
+
wk = str(week or week_of())
|
| 245 |
+
try:
|
| 246 |
+
stored = st.get(STORE_KEY) or {}
|
| 247 |
+
except Exception: # noqa: BLE001
|
| 248 |
+
stored = {}
|
| 249 |
+
by_week = stored.get(wk) if isinstance(stored, dict) else {}
|
| 250 |
+
by_week = by_week if isinstance(by_week, dict) else {}
|
| 251 |
+
uname = str(user or "").strip().lower()
|
| 252 |
+
if tenant_wide:
|
| 253 |
+
buckets = [b for b in by_week.values() if isinstance(b, dict)]
|
| 254 |
+
else:
|
| 255 |
+
buckets = [by_week.get(uname or "-")] if isinstance(by_week.get(uname or "-"), dict) else []
|
| 256 |
+
surfaces = []
|
| 257 |
+
for key in SURFACES:
|
| 258 |
+
folded = _fold([b.get(key) for b in buckets])
|
| 259 |
+
folded["surface"] = key
|
| 260 |
+
surfaces.append(folded)
|
| 261 |
+
total = sum(s["tokens"] for s in surfaces)
|
| 262 |
+
unmeasured = sum(s["unmeasured"] for s in surfaces)
|
| 263 |
+
out = {
|
| 264 |
+
"week": wk,
|
| 265 |
+
"resets": resets_at(),
|
| 266 |
+
"allowance": WEEKLY_ALLOWANCE,
|
| 267 |
+
"total": total,
|
| 268 |
+
# R9: over the allowance the product STILL WORKS and the bar goes red. This flag is the
|
| 269 |
+
# colour, never a wall β nothing in the API reads it.
|
| 270 |
+
"over": bool(WEEKLY_ALLOWANCE and total > WEEKLY_ALLOWANCE),
|
| 271 |
+
"calls": sum(s["calls"] for s in surfaces),
|
| 272 |
+
"unmeasured": unmeasured,
|
| 273 |
+
"scope": "tenant" if tenant_wide else "me",
|
| 274 |
+
"surfaces": surfaces,
|
| 275 |
+
}
|
| 276 |
+
if unmeasured:
|
| 277 |
+
# R6's second sentence: the number is short by an unknown amount and the payload says so
|
| 278 |
+
# rather than presenting a confident total.
|
| 279 |
+
out["note"] = (f"{unmeasured} call(s) this week did not report a token count, so the total "
|
| 280 |
+
f"above is a floor. The provider decides whether to report usage")
|
| 281 |
+
return out
|
platform/core/keychain.py
CHANGED
|
@@ -166,6 +166,52 @@ def read_fields(rt, entry_id):
|
|
| 166 |
return None
|
| 167 |
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
def odoo_creds(rt):
|
| 170 |
"""R3's resolver seam: the FIRST odoo-type entry's fields, else None (the caller falls
|
| 171 |
back to the environment). Deterministic order = insertion-id sort, so 'first' is stable."""
|
|
|
|
| 166 |
return None
|
| 167 |
|
| 168 |
|
| 169 |
+
def first_entry_of_type(rt, etype):
|
| 170 |
+
"""The metadata row of the FIRST entry of `etype`, or None. Never decrypts anything.
|
| 171 |
+
|
| 172 |
+
β W35-T45 β the same "first, by insertion-id sort" rule `_first_creds` resolves on, exposed as
|
| 173 |
+
an IDENTITY question rather than a credential one. `ensure_entry_of_type` needs to know whether
|
| 174 |
+
an entry exists WITHOUT unlocking it, and `list_entries` + a hand-rolled `next()` at each caller
|
| 175 |
+
is how two places come to disagree about which entry is "the" one.
|
| 176 |
+
"""
|
| 177 |
+
return next((e for e in list_entries(rt) if e.get('type') == str(etype or '')), None)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def ensure_entry_of_type(rt, etype, label, fields, username):
|
| 181 |
+
"""Create ONE entry of `etype` only if the tenant has none. `(row, why)`; `row` is None on skip.
|
| 182 |
+
|
| 183 |
+
β W35-T45 / R11 β the primitive tenant #0's env-to-keychain migration is built from, and the
|
| 184 |
+
reason it lives here is that only this module knows what "already has one" means: `odoo_creds`
|
| 185 |
+
resolves to the FIRST entry of a type, so a second one would be stored, invisible in every
|
| 186 |
+
resolver, and impossible to tell from the one that serves.
|
| 187 |
+
|
| 188 |
+
β IDEMPOTENT BY TYPE, NOT BY A FLAG. A migration that keys idempotency on a marker it writes
|
| 189 |
+
itself is one lost marker away from running twice ([[a-migration-that-runs-on-the-next-write]]);
|
| 190 |
+
keying it on the thing it would create cannot double-apply, whatever else fails.
|
| 191 |
+
|
| 192 |
+
β AND IT REFUSES ON A LOCKED KEYCHAIN RATHER THAN STORING A SECRET IN THE CLEAR. `add_entry`
|
| 193 |
+
would raise `KeychainLocked`; this answers with a REASON instead, because "there is no Fernet key
|
| 194 |
+
on this deployment" is an operator fact to report, not an exception to propagate out of a boot
|
| 195 |
+
thread where nobody reads it.
|
| 196 |
+
|
| 197 |
+
β `why` IS RETURNED EVEN ON SUCCESS-BY-SKIP. "Already migrated" and "could not migrate" are
|
| 198 |
+
different operator actions and a bare None cannot tell them apart.
|
| 199 |
+
"""
|
| 200 |
+
etype = str(etype or '').strip().lower()
|
| 201 |
+
if etype not in ENTRY_TYPES:
|
| 202 |
+
return None, f'{etype!r} is not a keychain entry type'
|
| 203 |
+
existing = first_entry_of_type(rt, etype)
|
| 204 |
+
if existing:
|
| 205 |
+
return None, f'this tenant already has a {etype} entry ({existing["id"]})'
|
| 206 |
+
if not unlocked():
|
| 207 |
+
return None, ('this deployment has no keychain key, and a credential must never be stored '
|
| 208 |
+
'in the clear')
|
| 209 |
+
try:
|
| 210 |
+
return add_entry(rt, label, etype, fields, username), ''
|
| 211 |
+
except Exception as exc: # noqa: BLE001
|
| 212 |
+
return None, f'{type(exc).__name__}: {exc}'
|
| 213 |
+
|
| 214 |
+
|
| 215 |
def odoo_creds(rt):
|
| 216 |
"""R3's resolver seam: the FIRST odoo-type entry's fields, else None (the caller falls
|
| 217 |
back to the environment). Deterministic order = insertion-id sort, so 'first' is stable."""
|
web/src/account/FeedbackPage.tsx
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/FeedbackPage.tsx β WAVE 35 Β· W35-T18 (owner item 13, ruling R8, C6).
|
| 3 |
+
//
|
| 4 |
+
// One composer, one category, one send. R8 puts every submission on the
|
| 5 |
+
// platform-wide operator plane: no tenant sees another's, and the submitting
|
| 6 |
+
// tenant cannot edit or delete what it sent β so there is deliberately no "my
|
| 7 |
+
// feedback" list here, and the 201 IS the receipt.
|
| 8 |
+
//
|
| 9 |
+
// β THE SHAPE IS THE ASSISTANT'S CENTRED COLUMN, READ AND NOT IMPORTED.
|
| 10 |
+
// `assistant/assistant.css` belongs to another session this wave, so the anatomy
|
| 11 |
+
// is borrowed (a measure-bounded column; a rounded field with the control row
|
| 12 |
+
// inside it; a round send button that goes dark when there is something to send)
|
| 13 |
+
// while every rule here is `.acct-*` in this module's own stylesheet. Same system
|
| 14 |
+
// to the eye, no shared file to collide over.
|
| 15 |
+
//
|
| 16 |
+
// β FOUR STATES, AND THE FIRST TWO ARE THE ONES THAT GET SKIPPED. The category
|
| 17 |
+
// list is the SERVER'S (B-1/E-9) β there is no local copy to fall back to β so
|
| 18 |
+
// while it is in flight there is no form, and if it FAILS there is a sentence and
|
| 19 |
+
// a retry, never an empty dropdown and never an invented list. A page that offers
|
| 20 |
+
// a category the door refuses is worse than a page that says it cannot load.
|
| 21 |
+
//
|
| 22 |
+
// β THE SURFACE IS SPLIT FROM THE READ, and the reason is evidence rather than
|
| 23 |
+
// taste: `renderToStaticMarkup` never runs an effect, so a shot of the fetching
|
| 24 |
+
// component can only photograph its own spinner. With the whole state as ONE
|
| 25 |
+
// prop, every state this page has β loading, failed, ready, sending, sent,
|
| 26 |
+
// refused β can be rendered and looked at without a server, which is the only
|
| 27 |
+
// evidence a lane with no server can produce.
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 31 |
+
|
| 32 |
+
import { loadFeedbackForm, sendFeedback } from "./accountApi";
|
| 33 |
+
import type { FeedbackForm } from "./accountApi";
|
| 34 |
+
import "./account.css";
|
| 35 |
+
|
| 36 |
+
/** The send arrow, in the same stroke vocabulary as the rest of the app's small glyphs. */
|
| 37 |
+
function SendIcon() {
|
| 38 |
+
return (
|
| 39 |
+
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" className="acct-send-icon">
|
| 40 |
+
<path d="M8 13.2V3.1" />
|
| 41 |
+
<path d="M3.8 7.3 8 3.1l4.2 4.2" />
|
| 42 |
+
</svg>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export type FeedbackLoad =
|
| 47 |
+
| { phase: "loading" }
|
| 48 |
+
| { phase: "ready"; form: FeedbackForm }
|
| 49 |
+
| { phase: "failed"; message: string };
|
| 50 |
+
|
| 51 |
+
/** Everything this page holds, in one value, so the surface below is a function of it. */
|
| 52 |
+
export interface FeedbackState {
|
| 53 |
+
load: FeedbackLoad;
|
| 54 |
+
category: string;
|
| 55 |
+
text: string;
|
| 56 |
+
sending: boolean;
|
| 57 |
+
/** The last send SUCCEEDED. Cleared the moment the person types again. */
|
| 58 |
+
sent: boolean;
|
| 59 |
+
/** The last send was REFUSED, and this is the server's own sentence. */
|
| 60 |
+
error: string;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export const EMPTY_FEEDBACK: FeedbackState = {
|
| 64 |
+
load: { phase: "loading" },
|
| 65 |
+
category: "",
|
| 66 |
+
text: "",
|
| 67 |
+
sending: false,
|
| 68 |
+
sent: false,
|
| 69 |
+
error: "",
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export function FeedbackSurface({
|
| 73 |
+
state,
|
| 74 |
+
onText,
|
| 75 |
+
onCategory,
|
| 76 |
+
onSend,
|
| 77 |
+
onRetry,
|
| 78 |
+
}: {
|
| 79 |
+
state: FeedbackState;
|
| 80 |
+
onText: (text: string) => void;
|
| 81 |
+
onCategory: (key: string) => void;
|
| 82 |
+
onSend: () => void;
|
| 83 |
+
onRetry: () => void;
|
| 84 |
+
}) {
|
| 85 |
+
const { load, category, text, sending, sent, error } = state;
|
| 86 |
+
const form = load.phase === "ready" ? load.form : null;
|
| 87 |
+
const tooLong = !!form && form.maxChars > 0 && text.length > form.maxChars;
|
| 88 |
+
const canSend = !!form && !!category && text.trim() !== "" && !tooLong && !sending;
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div className="acct-page">
|
| 92 |
+
<h1 className="acct-title">Feedback</h1>
|
| 93 |
+
<div className="acct-col">
|
| 94 |
+
{load.phase === "loading" ? (
|
| 95 |
+
<div className="acct-loading">
|
| 96 |
+
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 97 |
+
</div>
|
| 98 |
+
) : load.phase === "failed" ? (
|
| 99 |
+
// β NOT AN EMPTY FORM. The categories are the server's and there is no local copy, so
|
| 100 |
+
// a failed read means there is nothing to offer; drawing the composer anyway would
|
| 101 |
+
// invite somebody to write a paragraph into a control that cannot send it.
|
| 102 |
+
<div className="acct-failed">
|
| 103 |
+
<p className="acct-note">{load.message}</p>
|
| 104 |
+
<button type="button" className="acct-retry" onClick={onRetry}>
|
| 105 |
+
Try again
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
) : (
|
| 109 |
+
<>
|
| 110 |
+
{sent ? (
|
| 111 |
+
// The confirmation. It sits ABOVE the composer rather than replacing it, because
|
| 112 |
+
// the next thought is often the same person's, and a page that has to be navigated
|
| 113 |
+
// back to is a page that collects less of what R8 exists to collect.
|
| 114 |
+
<p className="acct-ok" role="status">
|
| 115 |
+
Thanks. That went to the Loopable team.
|
| 116 |
+
</p>
|
| 117 |
+
) : null}
|
| 118 |
+
{error ? (
|
| 119 |
+
<p className="acct-error" role="alert">
|
| 120 |
+
{error}
|
| 121 |
+
</p>
|
| 122 |
+
) : null}
|
| 123 |
+
|
| 124 |
+
<div className="acct-field">
|
| 125 |
+
<textarea
|
| 126 |
+
className="acct-prompt"
|
| 127 |
+
rows={5}
|
| 128 |
+
value={text}
|
| 129 |
+
placeholder="What is working, and what is not?"
|
| 130 |
+
aria-label="Your feedback"
|
| 131 |
+
onChange={(e) => onText(e.currentTarget.value)}
|
| 132 |
+
/>
|
| 133 |
+
<div className="acct-field-foot">
|
| 134 |
+
{/* β ONE dropdown, and its options are the SERVER'S list (B-1/E-9). A client
|
| 135 |
+
constant beside a server enum is two lists that drift, and the day they do
|
| 136 |
+
this control offers a value the door refuses. */}
|
| 137 |
+
<label className="acct-pick">
|
| 138 |
+
<span className="acct-pick-label">About</span>
|
| 139 |
+
<select
|
| 140 |
+
className="acct-select"
|
| 141 |
+
value={category}
|
| 142 |
+
aria-label="Category"
|
| 143 |
+
onChange={(e) => onCategory(e.currentTarget.value)}
|
| 144 |
+
>
|
| 145 |
+
{form?.categories.map((c) => (
|
| 146 |
+
<option key={c.key} value={c.key}>
|
| 147 |
+
{c.label}
|
| 148 |
+
</option>
|
| 149 |
+
))}
|
| 150 |
+
</select>
|
| 151 |
+
</label>
|
| 152 |
+
{/* The count appears only once there is something to count, and turns red only
|
| 153 |
+
when the limit is actually passed. A counter sitting at 0 on an empty field
|
| 154 |
+
is chrome describing itself. */}
|
| 155 |
+
{text.length > 0 && form && form.maxChars > 0 ? (
|
| 156 |
+
<span className={"acct-count" + (tooLong ? " is-over" : "")}>
|
| 157 |
+
{text.length} / {form.maxChars}
|
| 158 |
+
</span>
|
| 159 |
+
) : null}
|
| 160 |
+
<button
|
| 161 |
+
type="button"
|
| 162 |
+
className="acct-send"
|
| 163 |
+
disabled={!canSend}
|
| 164 |
+
aria-label="Send feedback"
|
| 165 |
+
title="Send feedback"
|
| 166 |
+
onClick={onSend}
|
| 167 |
+
>
|
| 168 |
+
{sending ? (
|
| 169 |
+
<span className="lp-spin" role="status" aria-label="Sending" />
|
| 170 |
+
) : (
|
| 171 |
+
<SendIcon />
|
| 172 |
+
)}
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
export default function FeedbackPage() {
|
| 184 |
+
const [state, setState] = useState<FeedbackState>(EMPTY_FEEDBACK);
|
| 185 |
+
const gen = useRef(0);
|
| 186 |
+
|
| 187 |
+
const read = useCallback(() => {
|
| 188 |
+
const mine = gen.current + 1;
|
| 189 |
+
gen.current = mine;
|
| 190 |
+
setState((s) => ({ ...s, load: { phase: "loading" }, error: "" }));
|
| 191 |
+
void loadFeedbackForm().then((r) => {
|
| 192 |
+
if (gen.current !== mine) return;
|
| 193 |
+
if (!r.ok) {
|
| 194 |
+
setState((s) => ({ ...s, load: { phase: "failed", message: r.message } }));
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
setState((s) => ({
|
| 198 |
+
...s,
|
| 199 |
+
load: { phase: "ready", form: r.value },
|
| 200 |
+
// The first category is selected rather than a "Choose one" placeholder: every category
|
| 201 |
+
// is a legitimate answer, so an unselected state would be a required field pretending to
|
| 202 |
+
// be a choice. β Only if the server sent one; an empty list selects nothing and the send
|
| 203 |
+
// refuses, which is honest rather than a submit that 400s.
|
| 204 |
+
category: r.value.categories[0]?.key ?? "",
|
| 205 |
+
}));
|
| 206 |
+
});
|
| 207 |
+
}, []);
|
| 208 |
+
|
| 209 |
+
useEffect(() => {
|
| 210 |
+
read();
|
| 211 |
+
return () => {
|
| 212 |
+
gen.current += 1;
|
| 213 |
+
};
|
| 214 |
+
}, [read]);
|
| 215 |
+
|
| 216 |
+
// β THE FETCH IS FIRED HERE, NEVER INSIDE A `setState` UPDATER. React may call an updater more
|
| 217 |
+
// than once for one dispatch (StrictMode does, deliberately), and a POST living in there would
|
| 218 |
+
// send the same feedback twice with nothing on screen saying so. The updater stays pure; the
|
| 219 |
+
// effectful call reads the state it already has.
|
| 220 |
+
const send = () => {
|
| 221 |
+
if (state.load.phase !== "ready" || state.sending) return;
|
| 222 |
+
const body = state.text.trim();
|
| 223 |
+
if (!state.category || body === "") return;
|
| 224 |
+
setState((s) => ({ ...s, sending: true, error: "" }));
|
| 225 |
+
void sendFeedback(state.category, body).then((r) => {
|
| 226 |
+
// β THE COMPOSER IS NOT CLEARED ON A REFUSAL. Clearing it would destroy what the person
|
| 227 |
+
// wrote and leave them holding a sentence about why it did not arrive, which is the worst
|
| 228 |
+
// possible pair. The text stays exactly where it is and the reason goes above it.
|
| 229 |
+
setState((cur) =>
|
| 230 |
+
r.ok
|
| 231 |
+
? { ...cur, sending: false, sent: true, error: "", text: "" }
|
| 232 |
+
: { ...cur, sending: false, sent: false, error: r.message }
|
| 233 |
+
);
|
| 234 |
+
});
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
return (
|
| 238 |
+
<FeedbackSurface
|
| 239 |
+
state={state}
|
| 240 |
+
onText={(text) => setState((s) => ({ ...s, text, sent: false }))}
|
| 241 |
+
onCategory={(category) => setState((s) => ({ ...s, category }))}
|
| 242 |
+
onSend={send}
|
| 243 |
+
onRetry={read}
|
| 244 |
+
/>
|
| 245 |
+
);
|
| 246 |
+
}
|
web/src/account/SubscriptionPage.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/SubscriptionPage.tsx β WAVE 35 Β· W35-T20 (owner item 13, contract C6).
|
| 3 |
+
//
|
| 4 |
+
// Owner item 13, verbatim: *"add below settings the following button: Feedback β¦,
|
| 5 |
+
// Usage credits β¦, and a Subscription module β¦ Specialized for the Royal Imports
|
| 6 |
+
// tenant, do not show it."*
|
| 7 |
+
//
|
| 8 |
+
// This is the third of those three, and the only one with nothing behind it yet.
|
| 9 |
+
// So it says so, in the words this app already uses for a boarded door
|
| 10 |
+
// (`Shell.tsx`'s templates note: "Under construction. β¦ will open here in a later
|
| 11 |
+
// release."). One voice for one state β DESIGN.md 1's "variety is a defect".
|
| 12 |
+
//
|
| 13 |
+
// β THIS PAGE DOES NOT HIDE ITSELF, AND THAT IS THE TICKET'S OWN TRAP.
|
| 14 |
+
// C6 puts the Royal Imports rule in TWO places that are both the frame's: the
|
| 15 |
+
// account MENU does not draw the row, and the ROUTE refuses. A third opinion here
|
| 16 |
+
// would be a third thing to keep in step, and the day they disagree the reader
|
| 17 |
+
// gets a menu row that opens a page which says it does not exist. The page renders
|
| 18 |
+
// the same for every tenant that can reach it, because reaching it IS the decision.
|
| 19 |
+
//
|
| 20 |
+
// β AND IT TAKES NO PROPS, WHICH IS ALSO A DECISION rather than an omission.
|
| 21 |
+
// The wave's rule is that a prop must be REQUIRED, never optional β an optional one
|
| 22 |
+
// degrades to "the page does not exist", which is indistinguishable from never
|
| 23 |
+
// built. That rule constrains the props that EXIST; it does not ask for a prop that
|
| 24 |
+
// carries nothing. There is nothing this page needs from the frame: it holds no
|
| 25 |
+
// state, makes no call, and takes no decision the frame has already taken. A
|
| 26 |
+
// `tenant` prop here would be exactly the second opinion the paragraph above
|
| 27 |
+
// refuses. The mount is proven by A's `verify_wiring` row (W2), not by a signature.
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
import "./account.css";
|
| 31 |
+
|
| 32 |
+
export default function SubscriptionPage() {
|
| 33 |
+
return (
|
| 34 |
+
<div className="acct-page">
|
| 35 |
+
<h1 className="acct-title">Subscription</h1>
|
| 36 |
+
<div className="acct-col">
|
| 37 |
+
{/* β TWO SHORT SENTENCES AND NO TOUR (DESIGN.md 4 / wave-22 R13: the app does not
|
| 38 |
+
narrate itself). The reader arrived here on purpose and wants one fact: is there
|
| 39 |
+
anything to do? There is not, and every further word would be spent on someone
|
| 40 |
+
who is already leaving. No feature list, no roadmap, no "in the meantime". */}
|
| 41 |
+
<p className="acct-note">
|
| 42 |
+
Under construction. Plans, billing and invoices will open here in a later release.
|
| 43 |
+
</p>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
web/src/account/UsagePage.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/UsagePage.tsx β WAVE 35 Β· W35-T19 (owner item 13, ruling R9, C6/C7).
|
| 3 |
+
//
|
| 4 |
+
// R9: ONE meter for every AI surface. It REPORTS weekly usage against an
|
| 5 |
+
// allowance and does not cut anyone off this wave; over the allowance the
|
| 6 |
+
// product still works and the bar is red.
|
| 7 |
+
//
|
| 8 |
+
// ββ THIS PAGE MAY NOT INVENT A FIGURE, AND THAT IS THE WHOLE TICKET.
|
| 9 |
+
// Every number here comes off `GET /usage` β the week, the reset date, the
|
| 10 |
+
// allowance, each surface's tokens and calls. Nothing is derived from a clock,
|
| 11 |
+
// nothing is estimated, and a surface with no ledger lines shows **0**, not a
|
| 12 |
+
// blank and not an omission (E-9 guarantees all four surfaces are always
|
| 13 |
+
// present, so there is no missing-row branch to get wrong).
|
| 14 |
+
// [[no-unverifiable-aggregates]]
|
| 15 |
+
//
|
| 16 |
+
// β AND `unmeasured` IS RENDERED, NOT SWALLOWED. Those are real calls whose
|
| 17 |
+
// provider declined to report a token count, so the total beside them is a
|
| 18 |
+
// FLOOR. A page that hides them presents the floor as a complete figure, which
|
| 19 |
+
// is the cost-surprise failure one step removed β the same reason
|
| 20 |
+
// `usage_ledger` books an unmeasured call as unknown rather than as zero.
|
| 21 |
+
//
|
| 22 |
+
// β SURFACE/READ SPLIT, as on Home and Starred: `renderToStaticMarkup` runs no
|
| 23 |
+
// effect, so a shot of the fetching component photographs only its spinner.
|
| 24 |
+
// ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 27 |
+
|
| 28 |
+
import { fmt } from "../ui/fmt";
|
| 29 |
+
import { loadUsage } from "./accountApi";
|
| 30 |
+
import type { Usage, UsageSurface } from "./accountApi";
|
| 31 |
+
import "./account.css";
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* The four surfaces C7 names, in the order R9 lists them.
|
| 35 |
+
*
|
| 36 |
+
* β AN UNKNOWN KEY IS HUMANISED, NEVER DROPPED. Dropping it would hide real usage from a meter
|
| 37 |
+
* whose whole job is to account for it, and printing the raw key would put an internal identifier
|
| 38 |
+
* on screen. The day a fifth LLM entry point lands (this product added two in one wave) it shows
|
| 39 |
+
* up here as a readable name with the right number beside it, and somebody can then decide what
|
| 40 |
+
* to call it.
|
| 41 |
+
*/
|
| 42 |
+
const SURFACE_LABEL: Record<string, string> = {
|
| 43 |
+
assistant: "Assistant",
|
| 44 |
+
field_agent: "Field agents",
|
| 45 |
+
ai_review: "Reviews",
|
| 46 |
+
automation_draft: "Drafting",
|
| 47 |
+
};
|
| 48 |
+
const ORDER = ["assistant", "field_agent", "ai_review", "automation_draft"];
|
| 49 |
+
|
| 50 |
+
export function surfaceLabel(key: string): string {
|
| 51 |
+
const known = SURFACE_LABEL[key];
|
| 52 |
+
if (known) return known;
|
| 53 |
+
const words = key.replace(/[_-]+/g, " ").trim();
|
| 54 |
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** C7's order first, then anything the server added, alphabetically. */
|
| 58 |
+
export function orderSurfaces(surfaces: UsageSurface[]): UsageSurface[] {
|
| 59 |
+
const rank = (s: UsageSurface) => {
|
| 60 |
+
const i = ORDER.indexOf(s.surface);
|
| 61 |
+
return i === -1 ? ORDER.length : i;
|
| 62 |
+
};
|
| 63 |
+
return [...surfaces].sort(
|
| 64 |
+
(a, b) => rank(a) - rank(b) || surfaceLabel(a.surface).localeCompare(surfaceLabel(b.surface))
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* The bar's fill, as a percentage, CLAMPED to 100.
|
| 70 |
+
*
|
| 71 |
+
* β The clamp is what keeps "over the allowance" a COLOUR rather than a bar that overflows its
|
| 72 |
+
* own track and paints across the page. The number above the bar is unclamped and is where the
|
| 73 |
+
* overage is actually read.
|
| 74 |
+
* β An allowance of 0 (unset) yields 0 rather than a division by zero: no allowance means the
|
| 75 |
+
* meter has nothing to measure against, and a full red bar would be an assertion nobody made.
|
| 76 |
+
*/
|
| 77 |
+
export function barPct(value: number, of: number): number {
|
| 78 |
+
if (!(of > 0)) return 0;
|
| 79 |
+
return Math.max(0, Math.min(100, (value / of) * 100));
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export type UsageLoad =
|
| 83 |
+
| { phase: "loading" }
|
| 84 |
+
| { phase: "ready"; usage: Usage }
|
| 85 |
+
| { phase: "failed"; message: string };
|
| 86 |
+
|
| 87 |
+
function Bar({ pct, over }: { pct: number; over: boolean }) {
|
| 88 |
+
return (
|
| 89 |
+
<span className="acct-bar" aria-hidden="true">
|
| 90 |
+
<span className={"acct-bar-fill" + (over ? " is-over" : "")} style={{ width: `${pct}%` }} />
|
| 91 |
+
</span>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function UsageSurfaceRows({ usage }: { usage: Usage }) {
|
| 96 |
+
// β SCALED TO THE LARGEST SURFACE, NOT TO THE WEEK'S TOTAL, and the difference is legibility
|
| 97 |
+
// rather than accuracy. `total` is the week's whole figure and can legitimately exceed the sum
|
| 98 |
+
// of these four (unattributed calls are counted there and belong to nobody), so scaling to it
|
| 99 |
+
// shrinks every bar by an amount the reader cannot see the cause of. Scaled to the biggest row,
|
| 100 |
+
// the bars answer the question a row of bars is actually asked: which surface is spending most.
|
| 101 |
+
// The absolute number sits beside each one, so nothing here is only readable as a shape.
|
| 102 |
+
const biggest = usage.surfaces.reduce((m, s) => Math.max(m, s.tokens), 0);
|
| 103 |
+
return (
|
| 104 |
+
<div className="acct-rows">
|
| 105 |
+
{orderSurfaces(usage.surfaces).map((s) => (
|
| 106 |
+
<div key={s.surface} className="acct-row">
|
| 107 |
+
<span className="acct-row-name">{surfaceLabel(s.surface)}</span>
|
| 108 |
+
<Bar pct={barPct(s.tokens, biggest)} over={false} />
|
| 109 |
+
<span className="acct-row-num">{fmt.int(s.tokens)}</span>
|
| 110 |
+
<span className="acct-row-sub">
|
| 111 |
+
{fmt.int(s.calls)} {s.calls === 1 ? "call" : "calls"}
|
| 112 |
+
{/* β NOT HOVER-ONLY. A surface whose provider declined to report tokens has a
|
| 113 |
+
token number that is a floor, and D-253 is a booked defect whose entire content
|
| 114 |
+
is "the explanation was satisfied only on hover". */}
|
| 115 |
+
{s.unmeasured > 0 ? `, ${fmt.int(s.unmeasured)} not measured` : ""}
|
| 116 |
+
</span>
|
| 117 |
+
</div>
|
| 118 |
+
))}
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function UsageView({
|
| 124 |
+
load,
|
| 125 |
+
scope,
|
| 126 |
+
canSeeTenant,
|
| 127 |
+
onScope,
|
| 128 |
+
onRetry,
|
| 129 |
+
}: {
|
| 130 |
+
load: UsageLoad;
|
| 131 |
+
scope: "me" | "tenant";
|
| 132 |
+
/**
|
| 133 |
+
* Whether the workspace view is reachable AT ALL for this account.
|
| 134 |
+
*
|
| 135 |
+
* β THE TOGGLE IS NOT DRAWN WHEN IT IS NOT, and that is R8's fake-affordance rule applied to a
|
| 136 |
+
* permission: `?scope=tenant` is admin-only and 403s for everybody else, so an always-visible
|
| 137 |
+
* control would be a button most accounts can only be refused by.
|
| 138 |
+
*/
|
| 139 |
+
canSeeTenant: boolean;
|
| 140 |
+
onScope: (scope: "me" | "tenant") => void;
|
| 141 |
+
onRetry: () => void;
|
| 142 |
+
}) {
|
| 143 |
+
return (
|
| 144 |
+
<div className="acct-page">
|
| 145 |
+
<h1 className="acct-title">Usage credits</h1>
|
| 146 |
+
<div className="acct-col">
|
| 147 |
+
{canSeeTenant ? (
|
| 148 |
+
<div className="acct-scope" role="group" aria-label="Whose usage">
|
| 149 |
+
{(["me", "tenant"] as const).map((key) => (
|
| 150 |
+
<button
|
| 151 |
+
key={key}
|
| 152 |
+
type="button"
|
| 153 |
+
className={"acct-scope-btn" + (scope === key ? " is-on" : "")}
|
| 154 |
+
aria-pressed={scope === key}
|
| 155 |
+
onClick={() => onScope(key)}
|
| 156 |
+
>
|
| 157 |
+
{key === "me" ? "You" : "Workspace"}
|
| 158 |
+
</button>
|
| 159 |
+
))}
|
| 160 |
+
</div>
|
| 161 |
+
) : null}
|
| 162 |
+
{load.phase === "loading" ? (
|
| 163 |
+
<div className="acct-loading">
|
| 164 |
+
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 165 |
+
</div>
|
| 166 |
+
) : load.phase === "failed" ? (
|
| 167 |
+
// β NOT A ZERO METER. A failed read and a quiet week look identical on screen unless
|
| 168 |
+
// this branch exists, and a meter reading zero is the most reassuring possible lie.
|
| 169 |
+
<div className="acct-failed">
|
| 170 |
+
<p className="acct-note">{load.message}</p>
|
| 171 |
+
<button type="button" className="acct-retry" onClick={onRetry}>
|
| 172 |
+
Try again
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
) : (
|
| 176 |
+
<>
|
| 177 |
+
<div className="acct-meter">
|
| 178 |
+
{/* β THE METER SAYS WHOSE USAGE IT IS, IN WORDS, and that is not decoration. The
|
| 179 |
+
allowance is a WORKSPACE number; the total beside it is this account's own
|
| 180 |
+
unless the scope says otherwise. A meter that shows a personal figure against a
|
| 181 |
+
shared limit without saying so is a ratio nobody can read correctly, and it is
|
| 182 |
+
the one way this page can be wrong while every number on it is right. */}
|
| 183 |
+
<div className="acct-meter-head">
|
| 184 |
+
<span className="acct-meter-who">
|
| 185 |
+
{load.usage.scope === "tenant" ? "This workspace" : "You"}, this week
|
| 186 |
+
</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="acct-meter-head">
|
| 189 |
+
<span className={"acct-meter-value" + (load.usage.over ? " is-over" : "")}>
|
| 190 |
+
{fmt.int(load.usage.total)}
|
| 191 |
+
</span>
|
| 192 |
+
<span className="acct-meter-of">
|
| 193 |
+
of {fmt.int(load.usage.allowance)} tokens
|
| 194 |
+
</span>
|
| 195 |
+
</div>
|
| 196 |
+
<Bar pct={barPct(load.usage.total, load.usage.allowance)} over={load.usage.over} />
|
| 197 |
+
<div className="acct-meter-foot">
|
| 198 |
+
{/* Both facts are the SERVER'S: the week it counted and the date it rolls over.
|
| 199 |
+
A week boundary computed in the browser can disagree with the bucketing that
|
| 200 |
+
produced the number above it, and then the page states a period nobody
|
| 201 |
+
measured. */}
|
| 202 |
+
<span>
|
| 203 |
+
Week {load.usage.week}. Resets {fmt.date(load.usage.resets)}.
|
| 204 |
+
</span>
|
| 205 |
+
<span className="acct-meter-calls">
|
| 206 |
+
{fmt.int(load.usage.calls)} {load.usage.calls === 1 ? "call" : "calls"}
|
| 207 |
+
</span>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* β R9's whole point, in one line: over the allowance it STILL WORKS. The bar is
|
| 212 |
+
red, nothing is cut off, and the sentence says which of those two is true so
|
| 213 |
+
nobody has to infer it from a colour. */}
|
| 214 |
+
{load.usage.over ? (
|
| 215 |
+
<p className="acct-error" role="status">
|
| 216 |
+
This workspace is over its weekly allowance. Nothing is cut off; the AI surfaces
|
| 217 |
+
keep working.
|
| 218 |
+
</p>
|
| 219 |
+
) : null}
|
| 220 |
+
|
| 221 |
+
{/* The server's own sentence, present only when something really was unmeasured. */}
|
| 222 |
+
{load.usage.note ? <p className="acct-note acct-note--inline">{load.usage.note}</p> : null}
|
| 223 |
+
|
| 224 |
+
<h2 className="acct-section">By surface</h2>
|
| 225 |
+
<UsageSurfaceRows usage={load.usage} />
|
| 226 |
+
|
| 227 |
+
{load.usage.unattributed > 0 ? (
|
| 228 |
+
// β LABELLED A WIRING GAP, NOT USAGE (E-9). These are AI calls this container could
|
| 229 |
+
// not attribute to an account. Folding them into somebody's total would be an
|
| 230 |
+
// invented attribution, and leaving them out entirely would understate the week.
|
| 231 |
+
<p className="acct-note acct-note--inline">
|
| 232 |
+
{fmt.int(load.usage.unattributed)} tokens this week could not be attributed to an
|
| 233 |
+
account. That is a wiring gap in the meter, not somebody's usage.
|
| 234 |
+
</p>
|
| 235 |
+
) : null}
|
| 236 |
+
</>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export default function UsagePage() {
|
| 244 |
+
const [load, setLoad] = useState<UsageLoad>({ phase: "loading" });
|
| 245 |
+
const [scope, setScope] = useState<"me" | "tenant">("me");
|
| 246 |
+
/**
|
| 247 |
+
* Whether the workspace view exists for this account, ANSWERED BY ASKING ONCE.
|
| 248 |
+
*
|
| 249 |
+
* β WHY A PROBE RATHER THAN A ROLE CHECK. The frame knows the role, but these pages take no
|
| 250 |
+
* props by contract (C6's trap: a page that decides its own visibility is a second opinion that
|
| 251 |
+
* will one day disagree with the route). The route itself is the authority, so the page asks it
|
| 252 |
+
* once: a 200 means the door is open and the toggle is real; a 403 means it is not and no
|
| 253 |
+
* control is drawn. β That 403 is an ANSWER, not an error β it is never shown to the reader.
|
| 254 |
+
* β AND IT IS WHAT GIVES `?scope=tenant` A DOOR. E built it admin-only; without this it would
|
| 255 |
+
* be a finished capability with no caller, which is this repo's most-repeated failure
|
| 256 |
+
* [[reachable-is-not-the-same-as-built]].
|
| 257 |
+
*/
|
| 258 |
+
const [canSeeTenant, setCanSeeTenant] = useState(false);
|
| 259 |
+
const gen = useRef(0);
|
| 260 |
+
|
| 261 |
+
const read = useCallback((which: "me" | "tenant") => {
|
| 262 |
+
const mine = gen.current + 1;
|
| 263 |
+
gen.current = mine;
|
| 264 |
+
setLoad({ phase: "loading" });
|
| 265 |
+
void loadUsage(which === "tenant" ? "tenant" : undefined).then((r) => {
|
| 266 |
+
if (gen.current !== mine) return;
|
| 267 |
+
setLoad(r.ok ? { phase: "ready", usage: r.value } : { phase: "failed", message: r.message });
|
| 268 |
+
});
|
| 269 |
+
}, []);
|
| 270 |
+
|
| 271 |
+
useEffect(() => {
|
| 272 |
+
read(scope);
|
| 273 |
+
return () => {
|
| 274 |
+
gen.current += 1;
|
| 275 |
+
};
|
| 276 |
+
}, [read, scope]);
|
| 277 |
+
|
| 278 |
+
useEffect(() => {
|
| 279 |
+
let live = true;
|
| 280 |
+
void loadUsage("tenant").then((r) => {
|
| 281 |
+
if (live) setCanSeeTenant(r.ok);
|
| 282 |
+
});
|
| 283 |
+
return () => {
|
| 284 |
+
live = false;
|
| 285 |
+
};
|
| 286 |
+
}, []);
|
| 287 |
+
|
| 288 |
+
return (
|
| 289 |
+
<UsageView
|
| 290 |
+
load={load}
|
| 291 |
+
scope={scope}
|
| 292 |
+
canSeeTenant={canSeeTenant}
|
| 293 |
+
onScope={setScope}
|
| 294 |
+
onRetry={() => read(scope)}
|
| 295 |
+
/>
|
| 296 |
+
);
|
| 297 |
+
}
|
web/src/account/account.css
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
account/account.css β WAVE 35, owner item 13: the three surfaces that hang off the
|
| 3 |
+
account button (Feedback, Usage credits, Subscription).
|
| 4 |
+
|
| 5 |
+
β ITS OWN STYLESHEET, NOT index.css. That file belongs to the INTEGRATOR for the whole
|
| 6 |
+
wave, and the wave's own rule is that a new surface brings its own sheet. Every selector
|
| 7 |
+
here is .acct-*; nothing in this file redefines a .home-*, .shell-* or .cg-* rule, so
|
| 8 |
+
nothing here can win or lose a source-order fight with the shared sheet.
|
| 9 |
+
|
| 10 |
+
β .acct-page RESTATES .shell-home's five box declarations rather than reusing that class,
|
| 11 |
+
and that is deliberate rather than lazy. .shell-home is Home's box by name and by its own
|
| 12 |
+
region header; borrowing it would make every future Home layout change silently a change
|
| 13 |
+
to three account pages. The VALUES are copied on purpose β DESIGN.md 2's sibling rule
|
| 14 |
+
("a panel matches its sibling's rendered values, not merely the scale") is what makes the
|
| 15 |
+
account pages read as the same system as Home, and .shell-main is overflow:hidden, so a
|
| 16 |
+
page that does not own its own scroll box simply loses everything below the fold.
|
| 17 |
+
|
| 18 |
+
β EVERY font-size IS A --lp-fs-* TOKEN. verify_ui globs src/**.css (every stylesheet in
|
| 19 |
+
the tree, not just index.css) and goes RED on a literal β wave-21 R5.
|
| 20 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
+
|
| 22 |
+
.acct-page {
|
| 23 |
+
height: 100%;
|
| 24 |
+
overflow-y: auto;
|
| 25 |
+
padding: 34px 44px 56px;
|
| 26 |
+
box-sizing: border-box;
|
| 27 |
+
background: var(--lp-wash);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* A short plain noun, same size and weight as .home-title, because it is the same role on a
|
| 31 |
+
sibling surface. DESIGN.md 2: headers are 1 to 3 words, never a sentence. */
|
| 32 |
+
.acct-title {
|
| 33 |
+
margin: 0 0 20px;
|
| 34 |
+
font-size: var(--lp-fs-lg);
|
| 35 |
+
font-weight: 650;
|
| 36 |
+
color: var(--lp-ink);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* THE ONE COLUMN. Every account surface is a reading-width column, not a full-bleed page:
|
| 40 |
+
a composer, a meter list and a note are all narrow objects, and stretching them to a
|
| 41 |
+
1600px window is how a page stops looking like it was designed. The reference the owner
|
| 42 |
+
gave for the assistant uses the same centred column for the same reason. */
|
| 43 |
+
.acct-col {
|
| 44 |
+
max-width: 640px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ββ the honest note (Subscription today; any boarded account surface tomorrow) βββββββββ
|
| 48 |
+
ONE containment layer: a hairline panel on the wash, no card inside a card, no shadow
|
| 49 |
+
(DESIGN.md 4). It is a panel rather than a bare paragraph so the emptiness reads as
|
| 50 |
+
deliberate instead of as a page that failed to load. */
|
| 51 |
+
.acct-note {
|
| 52 |
+
margin: 0;
|
| 53 |
+
padding: 16px 18px;
|
| 54 |
+
border: 1px solid var(--lp-line);
|
| 55 |
+
border-radius: var(--lp-r-md);
|
| 56 |
+
background: var(--lp-surface);
|
| 57 |
+
font-size: var(--lp-fs-xs);
|
| 58 |
+
line-height: var(--lp-lh);
|
| 59 |
+
color: var(--lp-muted);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ββ loading, and the way back from a failed read βββββββββββββββββββββββββββββββββββββββββββ */
|
| 63 |
+
.acct-loading {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: center;
|
| 67 |
+
min-height: 180px;
|
| 68 |
+
}
|
| 69 |
+
.acct-failed {
|
| 70 |
+
display: flex;
|
| 71 |
+
align-items: center;
|
| 72 |
+
gap: 12px;
|
| 73 |
+
}
|
| 74 |
+
/* DESIGN.md 4's quiet button, with the UA chrome reset explicitly (the wave-19 "mystery outer
|
| 75 |
+
lines and tinted fill" rule). */
|
| 76 |
+
.acct-retry {
|
| 77 |
+
flex: 0 0 auto;
|
| 78 |
+
padding: 6px 12px;
|
| 79 |
+
border: 1px solid var(--lp-line);
|
| 80 |
+
border-radius: var(--lp-r-md);
|
| 81 |
+
background: var(--lp-surface);
|
| 82 |
+
color: var(--lp-ink);
|
| 83 |
+
font: inherit;
|
| 84 |
+
font-size: var(--lp-fs-2xs);
|
| 85 |
+
font-weight: 600;
|
| 86 |
+
cursor: pointer;
|
| 87 |
+
}
|
| 88 |
+
.acct-retry:hover {
|
| 89 |
+
background: var(--lp-surface-2);
|
| 90 |
+
border-color: var(--lp-blue-deep);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* ββ the two answers a write can give βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
Green = positive, red = negative: the fixed semantics (DESIGN.md 3), on the DEEP companions
|
| 95 |
+
because a pastel on white is not a light aesthetic, it is an unreadable one. */
|
| 96 |
+
.acct-ok,
|
| 97 |
+
.acct-error {
|
| 98 |
+
margin: 0 0 12px;
|
| 99 |
+
padding: 9px 12px;
|
| 100 |
+
border-radius: var(--lp-r-md);
|
| 101 |
+
font-size: var(--lp-fs-2xs);
|
| 102 |
+
line-height: var(--lp-lh);
|
| 103 |
+
}
|
| 104 |
+
.acct-ok {
|
| 105 |
+
border: 1px solid var(--lp-green);
|
| 106 |
+
background: var(--lp-green-tint);
|
| 107 |
+
color: var(--lp-green-deep);
|
| 108 |
+
}
|
| 109 |
+
.acct-error {
|
| 110 |
+
border: 1px solid var(--lp-red);
|
| 111 |
+
background: var(--lp-red-tint);
|
| 112 |
+
color: var(--lp-red-deep);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* ββ the composer ββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
The ASSISTANT'S anatomy in this module's own rules: one rounded field that contains its own
|
| 117 |
+
control row, so the category and the send button read as part of the thing being written
|
| 118 |
+
rather than as chrome around it. `assistant.css` is another session's file this wave and is
|
| 119 |
+
deliberately not imported; the shape is borrowed, the rules are local.
|
| 120 |
+
β The radius is smaller than the assistant's 22px pill on purpose: this field is a paragraph
|
| 121 |
+
box, not a one-line prompt, and a heavily rounded tall box reads as a speech bubble. */
|
| 122 |
+
.acct-field {
|
| 123 |
+
border: 1px solid var(--lp-line);
|
| 124 |
+
border-radius: var(--lp-r-lg);
|
| 125 |
+
background: var(--lp-surface);
|
| 126 |
+
padding: 12px 10px 10px 14px;
|
| 127 |
+
}
|
| 128 |
+
.acct-field:focus-within {
|
| 129 |
+
border-color: var(--lp-blue-solid);
|
| 130 |
+
}
|
| 131 |
+
.acct-prompt {
|
| 132 |
+
display: block;
|
| 133 |
+
width: 100%;
|
| 134 |
+
border: 0;
|
| 135 |
+
outline: none;
|
| 136 |
+
padding: 0 6px 0 0;
|
| 137 |
+
background: transparent;
|
| 138 |
+
color: var(--lp-ink);
|
| 139 |
+
font: inherit;
|
| 140 |
+
font-size: var(--lp-fs-sm);
|
| 141 |
+
line-height: var(--lp-lh);
|
| 142 |
+
resize: vertical;
|
| 143 |
+
box-sizing: border-box;
|
| 144 |
+
}
|
| 145 |
+
.acct-prompt::placeholder {
|
| 146 |
+
color: var(--lp-muted);
|
| 147 |
+
}
|
| 148 |
+
.acct-field-foot {
|
| 149 |
+
display: flex;
|
| 150 |
+
align-items: center;
|
| 151 |
+
gap: 10px;
|
| 152 |
+
margin-top: 10px;
|
| 153 |
+
}
|
| 154 |
+
.acct-pick {
|
| 155 |
+
display: inline-flex;
|
| 156 |
+
align-items: center;
|
| 157 |
+
gap: 6px;
|
| 158 |
+
min-width: 0;
|
| 159 |
+
}
|
| 160 |
+
.acct-pick-label {
|
| 161 |
+
color: var(--lp-muted);
|
| 162 |
+
font-size: var(--lp-fs-2xs);
|
| 163 |
+
}
|
| 164 |
+
.acct-select {
|
| 165 |
+
max-width: 220px;
|
| 166 |
+
padding: 4px 8px;
|
| 167 |
+
border: 1px solid var(--lp-line);
|
| 168 |
+
border-radius: var(--lp-r-sm);
|
| 169 |
+
background: var(--lp-surface);
|
| 170 |
+
color: var(--lp-ink);
|
| 171 |
+
font: inherit;
|
| 172 |
+
font-size: var(--lp-fs-2xs);
|
| 173 |
+
cursor: pointer;
|
| 174 |
+
}
|
| 175 |
+
/* Tabular figures so the count does not jitter as it climbs. Red only once the limit is actually
|
| 176 |
+
passed: a counter that warns before anything is wrong trains people to ignore it. */
|
| 177 |
+
.acct-count {
|
| 178 |
+
color: var(--lp-muted);
|
| 179 |
+
font-size: var(--lp-fs-3xs);
|
| 180 |
+
font-variant-numeric: tabular-nums;
|
| 181 |
+
}
|
| 182 |
+
.acct-count.is-over {
|
| 183 |
+
color: var(--lp-red-deep);
|
| 184 |
+
font-weight: 600;
|
| 185 |
+
}
|
| 186 |
+
/* The send button takes the ink fill when it can send and the wash when it cannot, which is the
|
| 187 |
+
assistant's own rule: the control says whether there is anything to send before it is pressed. */
|
| 188 |
+
.acct-send {
|
| 189 |
+
flex: 0 0 auto;
|
| 190 |
+
margin-left: auto;
|
| 191 |
+
width: 30px;
|
| 192 |
+
height: 30px;
|
| 193 |
+
display: inline-flex;
|
| 194 |
+
align-items: center;
|
| 195 |
+
justify-content: center;
|
| 196 |
+
border: 0;
|
| 197 |
+
border-radius: var(--lp-r-pill);
|
| 198 |
+
background: var(--lp-ink);
|
| 199 |
+
color: var(--lp-surface);
|
| 200 |
+
font: inherit;
|
| 201 |
+
cursor: pointer;
|
| 202 |
+
}
|
| 203 |
+
.acct-send:disabled {
|
| 204 |
+
background: var(--lp-surface-2);
|
| 205 |
+
color: var(--lp-muted);
|
| 206 |
+
cursor: default;
|
| 207 |
+
}
|
| 208 |
+
.acct-send-icon {
|
| 209 |
+
fill: none;
|
| 210 |
+
stroke: currentColor;
|
| 211 |
+
stroke-width: 1.7;
|
| 212 |
+
stroke-linecap: round;
|
| 213 |
+
stroke-linejoin: round;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* ββ the usage meter (R9) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
One containment layer, hairline, no shadow. The bar is the only new SHAPE on these pages, and
|
| 218 |
+
the ticket asks for it by name ("over the allowance the bar is red"). */
|
| 219 |
+
.acct-note--inline {
|
| 220 |
+
margin: 12px 0 0;
|
| 221 |
+
padding: 9px 12px;
|
| 222 |
+
font-size: var(--lp-fs-2xs);
|
| 223 |
+
}
|
| 224 |
+
.acct-scope {
|
| 225 |
+
display: inline-flex;
|
| 226 |
+
gap: 2px;
|
| 227 |
+
margin-bottom: 14px;
|
| 228 |
+
padding: 2px;
|
| 229 |
+
border: 1px solid var(--lp-line);
|
| 230 |
+
border-radius: var(--lp-r-md);
|
| 231 |
+
background: var(--lp-surface);
|
| 232 |
+
}
|
| 233 |
+
/* Selection is TINT plus WEIGHT with the ink left alone, which is `.shell-nav-item.is-active`'s
|
| 234 |
+
rule. β NOT `--lp-blue-deep` on `--lp-blue-tint`: that pair measures 2.96:1, under the 4.5:1
|
| 235 |
+
text bar and under the 3:1 graphical one, and this control holds words. */
|
| 236 |
+
.acct-scope-btn {
|
| 237 |
+
padding: 4px 12px;
|
| 238 |
+
border: 0;
|
| 239 |
+
border-radius: var(--lp-r-sm);
|
| 240 |
+
background: transparent;
|
| 241 |
+
color: var(--lp-muted);
|
| 242 |
+
font: inherit;
|
| 243 |
+
font-size: var(--lp-fs-2xs);
|
| 244 |
+
font-weight: 600;
|
| 245 |
+
cursor: pointer;
|
| 246 |
+
}
|
| 247 |
+
.acct-scope-btn:hover {
|
| 248 |
+
background: var(--lp-surface-2);
|
| 249 |
+
}
|
| 250 |
+
.acct-scope-btn.is-on {
|
| 251 |
+
background: var(--lp-blue-tint);
|
| 252 |
+
color: var(--lp-ink);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.acct-meter {
|
| 256 |
+
padding: 16px 18px;
|
| 257 |
+
border: 1px solid var(--lp-line);
|
| 258 |
+
border-radius: var(--lp-r-md);
|
| 259 |
+
background: var(--lp-surface);
|
| 260 |
+
}
|
| 261 |
+
.acct-meter-head {
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: baseline;
|
| 264 |
+
gap: 8px;
|
| 265 |
+
flex-wrap: wrap;
|
| 266 |
+
}
|
| 267 |
+
.acct-meter-who {
|
| 268 |
+
font-size: var(--lp-fs-2xs);
|
| 269 |
+
font-weight: 600;
|
| 270 |
+
color: var(--lp-muted);
|
| 271 |
+
margin-bottom: 4px;
|
| 272 |
+
}
|
| 273 |
+
/* Tabular figures on every financial-grade number (DESIGN.md 2), so a total that climbs does not
|
| 274 |
+
make the label beside it dance. Weight 650, never a display weight. */
|
| 275 |
+
.acct-meter-value {
|
| 276 |
+
font-size: var(--lp-fs-xl);
|
| 277 |
+
font-weight: 650;
|
| 278 |
+
color: var(--lp-ink);
|
| 279 |
+
font-variant-numeric: tabular-nums;
|
| 280 |
+
}
|
| 281 |
+
/* Red = negative/over, the fixed semantic, on the deep companion. */
|
| 282 |
+
.acct-meter-value.is-over {
|
| 283 |
+
color: var(--lp-red-deep);
|
| 284 |
+
}
|
| 285 |
+
.acct-meter-of {
|
| 286 |
+
font-size: var(--lp-fs-xs);
|
| 287 |
+
color: var(--lp-muted);
|
| 288 |
+
font-variant-numeric: tabular-nums;
|
| 289 |
+
}
|
| 290 |
+
.acct-meter-foot {
|
| 291 |
+
display: flex;
|
| 292 |
+
align-items: baseline;
|
| 293 |
+
justify-content: space-between;
|
| 294 |
+
gap: 12px;
|
| 295 |
+
margin-top: 8px;
|
| 296 |
+
font-size: var(--lp-fs-2xs);
|
| 297 |
+
color: var(--lp-muted);
|
| 298 |
+
}
|
| 299 |
+
.acct-meter-calls {
|
| 300 |
+
flex: 0 0 auto;
|
| 301 |
+
font-variant-numeric: tabular-nums;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* The track is the wash so an empty meter still reads as a meter rather than as a missing
|
| 305 |
+
element; the fill is the brand at rest and red once the allowance is passed. */
|
| 306 |
+
.acct-bar {
|
| 307 |
+
display: block;
|
| 308 |
+
width: 100%;
|
| 309 |
+
height: 8px;
|
| 310 |
+
margin-top: 10px;
|
| 311 |
+
border-radius: var(--lp-r-pill);
|
| 312 |
+
background: var(--lp-wash);
|
| 313 |
+
overflow: hidden;
|
| 314 |
+
}
|
| 315 |
+
.acct-bar-fill {
|
| 316 |
+
display: block;
|
| 317 |
+
height: 100%;
|
| 318 |
+
border-radius: var(--lp-r-pill);
|
| 319 |
+
background: var(--lp-primary);
|
| 320 |
+
transition: width 0.18s var(--lp-fold-e);
|
| 321 |
+
}
|
| 322 |
+
.acct-bar-fill.is-over {
|
| 323 |
+
background: var(--lp-red-deep);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/* A short plain noun, muted, sentence case, matching `.home-section-title` exactly: the same role
|
| 327 |
+
on a sibling surface takes the same rendered values (DESIGN.md 2). */
|
| 328 |
+
.acct-section {
|
| 329 |
+
margin: 22px 0 9px;
|
| 330 |
+
font-size: var(--lp-fs-2xs);
|
| 331 |
+
font-weight: 600;
|
| 332 |
+
color: var(--lp-muted);
|
| 333 |
+
}
|
| 334 |
+
.acct-rows {
|
| 335 |
+
display: flex;
|
| 336 |
+
flex-direction: column;
|
| 337 |
+
gap: 2px;
|
| 338 |
+
}
|
| 339 |
+
/* One row per surface: name, share bar, tokens, calls. Fixed columns so the four numbers form a
|
| 340 |
+
column a reader can scan rather than four numbers at four different left edges. */
|
| 341 |
+
.acct-row {
|
| 342 |
+
display: grid;
|
| 343 |
+
grid-template-columns: 120px 1fr 92px 150px;
|
| 344 |
+
align-items: center;
|
| 345 |
+
gap: 12px;
|
| 346 |
+
padding: 8px 2px;
|
| 347 |
+
border-bottom: 1px solid var(--lp-line);
|
| 348 |
+
}
|
| 349 |
+
.acct-row:last-child {
|
| 350 |
+
border-bottom: 0;
|
| 351 |
+
}
|
| 352 |
+
.acct-row-name {
|
| 353 |
+
font-size: var(--lp-fs-xs);
|
| 354 |
+
font-weight: 600;
|
| 355 |
+
color: var(--lp-ink);
|
| 356 |
+
}
|
| 357 |
+
/* Numbers right-aligned, text left (DESIGN.md 2), tabular so the column lines up digit for digit. */
|
| 358 |
+
.acct-row-num {
|
| 359 |
+
text-align: right;
|
| 360 |
+
font-size: var(--lp-fs-xs);
|
| 361 |
+
color: var(--lp-ink);
|
| 362 |
+
font-variant-numeric: tabular-nums;
|
| 363 |
+
}
|
| 364 |
+
.acct-row-sub {
|
| 365 |
+
font-size: var(--lp-fs-2xs);
|
| 366 |
+
color: var(--lp-muted);
|
| 367 |
+
font-variant-numeric: tabular-nums;
|
| 368 |
+
}
|
| 369 |
+
.acct-row .acct-bar {
|
| 370 |
+
margin-top: 0;
|
| 371 |
+
height: 6px;
|
| 372 |
+
}
|
web/src/account/accountApi.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// account/accountApi.ts β WAVE 35, owner item 13: the wire behind the two account
|
| 3 |
+
// surfaces that have one (Feedback, contract C6/R8; Usage credits, C7/R9).
|
| 4 |
+
//
|
| 5 |
+
// β THE CATEGORY VOCABULARY IS THE SERVER'S AND IS FETCHED, NOT DECLARED HERE.
|
| 6 |
+
// That is a decision, not an omission: a client constant beside a server enum is
|
| 7 |
+
// two lists that drift, and the day they do the dropdown offers a value the door
|
| 8 |
+
// refuses. Asked and settled in mailbox B-1 / E-9 β the server serves the list,
|
| 9 |
+
// this file renders it, and there is no local fallback to drift TO.
|
| 10 |
+
//
|
| 11 |
+
// β AND THERE IS NO TENANT-SIDE READ OF A SUBMISSION AT ALL (R8). No "my
|
| 12 |
+
// feedback" list, no edit, no delete: the operator plane is the only reader. The
|
| 13 |
+
// 201 is the receipt. If that ever feels like a gap, it is R8, not a missing
|
| 14 |
+
// endpoint.
|
| 15 |
+
//
|
| 16 |
+
// β NOTHING ON THE USAGE SIDE IS COMPUTED HERE. The allowance, the week and the
|
| 17 |
+
// reset date all ride the payload (E-9), because a week boundary computed in the
|
| 18 |
+
// browser can disagree with the server's own bucketing β and then the page shows
|
| 19 |
+
// a number that traces to no ledger line, which is exactly what R9's meter must
|
| 20 |
+
// never do.
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "../apiContract";
|
| 24 |
+
|
| 25 |
+
/** The same discriminated result the other feature modules use. */
|
| 26 |
+
export type Result<T> =
|
| 27 |
+
| { ok: true; value: T }
|
| 28 |
+
| { ok: false; status: number; message: string };
|
| 29 |
+
|
| 30 |
+
async function call<T>(
|
| 31 |
+
path: string,
|
| 32 |
+
init: RequestInit,
|
| 33 |
+
read: (body: unknown) => T
|
| 34 |
+
): Promise<Result<T>> {
|
| 35 |
+
let res: Response;
|
| 36 |
+
try {
|
| 37 |
+
res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 38 |
+
} catch {
|
| 39 |
+
return { ok: false, status: 0, message: "Cannot reach the server." };
|
| 40 |
+
}
|
| 41 |
+
if (checkTenant(res)) return { ok: false, status: 0, message: "Reloading." };
|
| 42 |
+
if (res.status === 401) signal(UNAUTHORIZED_EVENT);
|
| 43 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 44 |
+
if (!res.ok) {
|
| 45 |
+
// β A 4xx MESSAGE IS POLICY AND IS SHOWN; a 5xx message is an internal detail and is not.
|
| 46 |
+
// `session.ts` states the same rule for sign-in, and E names each 400 case ("unknown
|
| 47 |
+
// category", "empty text", "over maxChars"), so the server's sentence is the useful one.
|
| 48 |
+
const detail = (body as { error?: { message?: string } } | null)?.error?.message;
|
| 49 |
+
return {
|
| 50 |
+
ok: false,
|
| 51 |
+
status: res.status,
|
| 52 |
+
message:
|
| 53 |
+
res.status >= 500 || !detail
|
| 54 |
+
? res.status >= 500
|
| 55 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 56 |
+
: `The server answered ${res.status}.`
|
| 57 |
+
: detail,
|
| 58 |
+
};
|
| 59 |
+
}
|
| 60 |
+
return { ok: true, value: read(body) };
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const row = (v: unknown): Record<string, unknown> =>
|
| 64 |
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
| 65 |
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 66 |
+
const int = (v: unknown): number =>
|
| 67 |
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
| 68 |
+
|
| 69 |
+
// ββ FEEDBACK (R8, C6) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
export interface FeedbackCategory {
|
| 72 |
+
key: string;
|
| 73 |
+
label: string;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export interface FeedbackForm {
|
| 77 |
+
categories: FeedbackCategory[];
|
| 78 |
+
maxChars: number;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* `GET /api/v1/feedback/form` β the category list and the length limit, both the server's.
|
| 83 |
+
*
|
| 84 |
+
* β A CATEGORY WITH NO LABEL IS DROPPED, never rendered as its key: an internal identifier in a
|
| 85 |
+
* dropdown is the same defect `parsePages` refuses one layer up ("a nav row with no name is a
|
| 86 |
+
* door with no sign on it"). And an EMPTY list is a real answer the caller must handle, not a
|
| 87 |
+
* reason to invent one.
|
| 88 |
+
*/
|
| 89 |
+
export function parseFeedbackForm(body: unknown): FeedbackForm {
|
| 90 |
+
const r = row(body);
|
| 91 |
+
const categories = (Array.isArray(r.categories) ? r.categories : [])
|
| 92 |
+
.map(row)
|
| 93 |
+
.filter((c) => str(c.key) !== "" && str(c.label) !== "")
|
| 94 |
+
.map((c) => ({ key: str(c.key), label: str(c.label) }));
|
| 95 |
+
return { categories, maxChars: int(r.maxChars) };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export function loadFeedbackForm(): Promise<Result<FeedbackForm>> {
|
| 99 |
+
return call("/feedback/form", {}, parseFeedbackForm);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export function sendFeedback(category: string, text: string): Promise<Result<{ id: string }>> {
|
| 103 |
+
return call(
|
| 104 |
+
"/feedback",
|
| 105 |
+
{
|
| 106 |
+
method: "POST",
|
| 107 |
+
headers: { "Content-Type": "application/json" },
|
| 108 |
+
body: JSON.stringify({ category, text }),
|
| 109 |
+
},
|
| 110 |
+
(b) => ({ id: str(row(b).id) })
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ββ USAGE (R9, C7) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½βββββ
|
| 115 |
+
|
| 116 |
+
/** One AI surface's week. Every surface is present at zero when unused (E-9), so nothing here
|
| 117 |
+
* branches on a missing row. */
|
| 118 |
+
export interface UsageSurface {
|
| 119 |
+
surface: string;
|
| 120 |
+
calls: number;
|
| 121 |
+
tokens: number;
|
| 122 |
+
tokensIn: number;
|
| 123 |
+
tokensOut: number;
|
| 124 |
+
/**
|
| 125 |
+
* Calls whose provider declined to report a token count.
|
| 126 |
+
*
|
| 127 |
+
* β NOT ZERO-PADDING. These are real calls with an UNKNOWN cost, which is why the total beside
|
| 128 |
+
* them is a FLOOR rather than a figure. A page that hides this presents the floor as complete,
|
| 129 |
+
* and that is the cost-surprise failure one step removed.
|
| 130 |
+
*/
|
| 131 |
+
unmeasured: number;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export interface Usage {
|
| 135 |
+
/** ISO year-week, UTC. A string that sorts chronologically, so nothing here parses a date. */
|
| 136 |
+
week: string;
|
| 137 |
+
/** The UTC date the counters roll over, as the server states it. */
|
| 138 |
+
resets: string;
|
| 139 |
+
allowance: number;
|
| 140 |
+
total: number;
|
| 141 |
+
over: boolean;
|
| 142 |
+
calls: number;
|
| 143 |
+
unmeasured: number;
|
| 144 |
+
/** `"me"` or `"tenant"`. Which question the numbers answer, decided by the server. */
|
| 145 |
+
scope: string;
|
| 146 |
+
surfaces: UsageSurface[];
|
| 147 |
+
/** The server's own sentence about `unmeasured`. Empty when there is nothing unmeasured. */
|
| 148 |
+
note: string;
|
| 149 |
+
/**
|
| 150 |
+
* Admin only: AI calls this container could not attribute to an account.
|
| 151 |
+
*
|
| 152 |
+
* β LABEL IT A WIRING GAP, NOT USAGE (E-9). It is the meter reporting on ITSELF, and folding it
|
| 153 |
+
* into somebody's total would be an invented attribution.
|
| 154 |
+
*/
|
| 155 |
+
unattributed: number;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export function parseUsage(body: unknown): Usage {
|
| 159 |
+
const r = row(body);
|
| 160 |
+
const surfaces = (Array.isArray(r.surfaces) ? r.surfaces : [])
|
| 161 |
+
.map(row)
|
| 162 |
+
.filter((s) => str(s.surface) !== "")
|
| 163 |
+
.map((s) => ({
|
| 164 |
+
surface: str(s.surface),
|
| 165 |
+
calls: int(s.calls),
|
| 166 |
+
tokens: int(s.tokens),
|
| 167 |
+
tokensIn: int(s.tokens_in),
|
| 168 |
+
tokensOut: int(s.tokens_out),
|
| 169 |
+
unmeasured: int(s.unmeasured),
|
| 170 |
+
}));
|
| 171 |
+
return {
|
| 172 |
+
week: str(r.week),
|
| 173 |
+
resets: str(r.resets),
|
| 174 |
+
allowance: int(r.allowance),
|
| 175 |
+
total: int(r.total),
|
| 176 |
+
over: r.over === true,
|
| 177 |
+
calls: int(r.calls),
|
| 178 |
+
unmeasured: int(r.unmeasured),
|
| 179 |
+
scope: str(r.scope) || "me",
|
| 180 |
+
surfaces,
|
| 181 |
+
note: str(r.note),
|
| 182 |
+
unattributed: int(r.unattributed),
|
| 183 |
+
};
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export function loadUsage(scope?: "tenant"): Promise<Result<Usage>> {
|
| 187 |
+
return call(scope === "tenant" ? "/usage?scope=tenant" : "/usage", {}, parseUsage);
|
| 188 |
+
}
|
web/src/apiContract.ts
CHANGED
|
@@ -228,6 +228,24 @@ export interface QueryOpenDetail {
|
|
| 228 |
*/
|
| 229 |
export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
/** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β a surface that does not
|
| 232 |
* scroll to a stage simply selects the automation. */
|
| 233 |
export interface AutomationOpenDetail {
|
|
|
|
| 228 |
*/
|
| 229 |
export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
|
| 230 |
|
| 231 |
+
/**
|
| 232 |
+
* ββ WAVE 35 Β· T08 (owner item 11, wiring W6) β "the agent list CHANGED". Owner: *"When I delete
|
| 233 |
+
* an Agent, it doesn't sync to the Home at all β¦ fix this for everything we display twice in Home."*
|
| 234 |
+
*
|
| 235 |
+
* β THE CAUSE IS AN OWNERSHIP SPLIT, NOT A MISSING REFETCH. The FRAME fetches the agent tiles Home
|
| 236 |
+
* draws, while `AutomationSurface` is "props-free by contract" and fetches its OWN list β so a
|
| 237 |
+
* delete inside the module updates the module and nothing tells the frame its copy is stale. Two
|
| 238 |
+
* readers of one server fact, and only one of them was told.
|
| 239 |
+
*
|
| 240 |
+
* β NO `detail`, DELIBERATELY. This says "your copy is stale", not "here is the new list": a
|
| 241 |
+
* payload would be a THIRD copy of the same fact, and the frame re-asking is one request against a
|
| 242 |
+
* route measured at ~360 ms. The emitter (D, `AutomationSurface`) must dispatch it for delete,
|
| 243 |
+
* rename AND create.
|
| 244 |
+
* β It is deliberately NOT a `navEpoch` bump: that would re-fetch `/nav`, a whole-document read of
|
| 245 |
+
* a 28.6 MB store (D-175/D-185), to refresh a list of agent tiles.
|
| 246 |
+
*/
|
| 247 |
+
export const AGENTS_CHANGED = "aios:agents-changed";
|
| 248 |
+
|
| 249 |
/** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β a surface that does not
|
| 250 |
* scroll to a stage simply selects the automation. */
|
| 251 |
export interface AutomationOpenDetail {
|
web/src/assistant/AssistantPage.tsx
CHANGED
|
@@ -675,6 +675,15 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 675 |
<QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
|
| 676 |
selectedId={activeQuery} />
|
| 677 |
</Suspense>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
) : (
|
| 679 |
<div className="as-opening">
|
| 680 |
<h1 className="as-opening-h">No Query views yet</h1>
|
|
|
|
| 675 |
<QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
|
| 676 |
selectedId={activeQuery} />
|
| 677 |
</Suspense>
|
| 678 |
+
) : !loaded ? (
|
| 679 |
+
/* β W35-T22, the SECOND state that paints something untrue for a frame. `queryList` is
|
| 680 |
+
empty until `GET /query` lands, so arriving here β a `#/query` link, a bookmark, a
|
| 681 |
+
reload β asserted "No Query views yet" to somebody who has twenty, then replaced it.
|
| 682 |
+
`loaded` is the flag the chat history one screen down already waits on; the two
|
| 683 |
+
empty states now agree about when they are entitled to speak. */
|
| 684 |
+
<p className="as-working" role="status" aria-label="Loading your Query views">
|
| 685 |
+
<span className="lp-spin" aria-hidden="true" />
|
| 686 |
+
</p>
|
| 687 |
) : (
|
| 688 |
<div className="as-opening">
|
| 689 |
<h1 className="as-opening-h">No Query views yet</h1>
|
web/src/automation/AutomationDetail.tsx
CHANGED
|
@@ -50,6 +50,7 @@ import type {
|
|
| 50 |
DiscoverEstimate,
|
| 51 |
DiscoverVocab,
|
| 52 |
SourcePreview,
|
|
|
|
| 53 |
TickState,
|
| 54 |
TriggerOption,
|
| 55 |
UserTable,
|
|
@@ -60,6 +61,7 @@ import {
|
|
| 60 |
deleteAutomation,
|
| 61 |
discoverEstimate,
|
| 62 |
fieldKeyFor,
|
|
|
|
| 63 |
listTables,
|
| 64 |
oauthStatus,
|
| 65 |
patchAutomation,
|
|
@@ -67,6 +69,8 @@ import {
|
|
| 67 |
runAutomation,
|
| 68 |
runBlock,
|
| 69 |
runRows,
|
|
|
|
|
|
|
| 70 |
toggleNode,
|
| 71 |
} from "./automationApi";
|
| 72 |
|
|
@@ -235,6 +239,18 @@ export default function AutomationDetail({
|
|
| 235 |
const [touched, setTouched] = useState(false);
|
| 236 |
const [triggerBusy, setTriggerBusy] = useState(false);
|
| 237 |
const [oauth, setOauth] = useState<OAuthStatus | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
/**
|
| 239 |
* β "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE β and forgetting that once
|
| 240 |
* made the Custom option DEAD. Everything else on the trigger face is derived from the
|
|
@@ -431,6 +447,29 @@ export default function AutomationDetail({
|
|
| 431 |
return () => ac.abort();
|
| 432 |
}, []);
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
const table = tables.find((t) => t.key === targetTable) || null;
|
| 435 |
|
| 436 |
/** ITEM 22 / D-70 / R12 β may Run be pressed, and what does it say if not. One definition,
|
|
@@ -881,6 +920,42 @@ export default function AutomationDetail({
|
|
| 881 |
}
|
| 882 |
};
|
| 883 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
const openDrill = async (entry: RunEntry) => {
|
| 885 |
if (drillFor === entry.ts) {
|
| 886 |
setDrill(null);
|
|
@@ -1432,6 +1507,72 @@ export default function AutomationDetail({
|
|
| 1432 |
</div>
|
| 1433 |
) : null}
|
| 1434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1435 |
<div className="auto-work">
|
| 1436 |
{/*
|
| 1437 |
β THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be
|
|
|
|
| 50 |
DiscoverEstimate,
|
| 51 |
DiscoverVocab,
|
| 52 |
SourcePreview,
|
| 53 |
+
StatementBatch,
|
| 54 |
TickState,
|
| 55 |
TriggerOption,
|
| 56 |
UserTable,
|
|
|
|
| 61 |
deleteAutomation,
|
| 62 |
discoverEstimate,
|
| 63 |
fieldKeyFor,
|
| 64 |
+
getAutomation,
|
| 65 |
listTables,
|
| 66 |
oauthStatus,
|
| 67 |
patchAutomation,
|
|
|
|
| 69 |
runAutomation,
|
| 70 |
runBlock,
|
| 71 |
runRows,
|
| 72 |
+
sendStatementBatch,
|
| 73 |
+
statementBatch,
|
| 74 |
toggleNode,
|
| 75 |
} from "./automationApi";
|
| 76 |
|
|
|
|
| 239 |
const [touched, setTouched] = useState(false);
|
| 240 |
const [triggerBusy, setTriggerBusy] = useState(false);
|
| 241 |
const [oauth, setOauth] = useState<OAuthStatus | null>(null);
|
| 242 |
+
/**
|
| 243 |
+
* ββ WAVE 35 Β· T36 / OWNER RULING R10 β THE PARKED STATEMENTS BATCH.
|
| 244 |
+
*
|
| 245 |
+
* β TWO PIECES OF STATE FOR TWO DIFFERENT FACTS, and collapsing them would lose one. The
|
| 246 |
+
* automation's own `pendingStatements` (count + notes) rides on the LIST and is already here on
|
| 247 |
+
* first paint, so the panel can say "12 statements are ready" with no round trip. `batch` is the
|
| 248 |
+
* per-customer detail, fetched only when a batch exists, because that call reads the live Odoo
|
| 249 |
+
* worklist and a rail paint must never pay for it.
|
| 250 |
+
*/
|
| 251 |
+
const [batch, setBatch] = useState<StatementBatch | null>(null);
|
| 252 |
+
const [sending, setSending] = useState(false);
|
| 253 |
+
const parked = automation.pendingStatements || null;
|
| 254 |
/**
|
| 255 |
* β "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE β and forgetting that once
|
| 256 |
* made the Custom option DEAD. Everything else on the trigger face is derived from the
|
|
|
|
| 447 |
return () => ac.abort();
|
| 448 |
}, []);
|
| 449 |
|
| 450 |
+
/**
|
| 451 |
+
* β W35-T36 β fetch the per-customer detail ONLY when a batch is actually parked.
|
| 452 |
+
*
|
| 453 |
+
* β THE GUARD IS THE POINT. This call reads the live Odoo collection worklist, so firing it on
|
| 454 |
+
* every automation anybody opens would put a multi-model Odoo read behind an ordinary click.
|
| 455 |
+
* `parked` comes free with the list payload, so the cheap fact gates the expensive one.
|
| 456 |
+
* β Keyed on the COUNT as well as the id: sending part of a batch changes what is parked, and a
|
| 457 |
+
* key of `id` alone would leave the review list showing customers who have just been invoiced.
|
| 458 |
+
*/
|
| 459 |
+
useEffect(() => {
|
| 460 |
+
if (!parked || !parked.count) {
|
| 461 |
+
setBatch(null);
|
| 462 |
+
return undefined;
|
| 463 |
+
}
|
| 464 |
+
const ac = new AbortController();
|
| 465 |
+
statementBatch(automation.id, ac.signal)
|
| 466 |
+
.then((r) => setBatch(r.pending))
|
| 467 |
+
// A batch we cannot read is not an error banner over the whole agent: the count and the
|
| 468 |
+
// notes are already on screen from the list payload, and they are the actionable half.
|
| 469 |
+
.catch(() => setBatch(null));
|
| 470 |
+
return () => ac.abort();
|
| 471 |
+
}, [automation.id, parked?.count]);
|
| 472 |
+
|
| 473 |
const table = tables.find((t) => t.key === targetTable) || null;
|
| 474 |
|
| 475 |
/** ITEM 22 / D-70 / R12 β may Run be pressed, and what does it say if not. One definition,
|
|
|
|
| 920 |
}
|
| 921 |
};
|
| 922 |
|
| 923 |
+
/**
|
| 924 |
+
* ββ WAVE 35 Β· T36 / R10 β THE CLICK THAT SENDS, and the only one in the client.
|
| 925 |
+
*
|
| 926 |
+
* β IT SENDS WHAT WAS PARKED, and passes no customer list: the review is what authorised the
|
| 927 |
+
* batch, so the payload must not be able to widen it. The server refuses a name that was not
|
| 928 |
+
* parked for the same reason from the other side.
|
| 929 |
+
* β THE OUTCOME IS REPORTED PER BUCKET, never as a bare count. `failed` and `skipped` are
|
| 930 |
+
* different facts β a guardrail refusal and "they already paid" are both "this one did not go"
|
| 931 |
+
* and only one of them is a problem β and a summary that added them together would hide which.
|
| 932 |
+
*/
|
| 933 |
+
const releaseBatch = async () => {
|
| 934 |
+
setProblem("");
|
| 935 |
+
setMessage("");
|
| 936 |
+
setSending(true);
|
| 937 |
+
try {
|
| 938 |
+
const out = await sendStatementBatch(automation.id);
|
| 939 |
+
const parts = [`${out.sent.length} sent`];
|
| 940 |
+
if (out.failed.length) parts.push(`${out.failed.length} failed`);
|
| 941 |
+
if (out.skipped.length) parts.push(`${out.skipped.length} skipped`);
|
| 942 |
+
setMessage(parts.join(", "));
|
| 943 |
+
/* β HANDS ON A FRESH DEFINITION RATHER THAN RELOADING THE LIST β the wave-31 T31 rule that
|
| 944 |
+
every other write door here follows, and `verify_automation_ui` counts the two shapes
|
| 945 |
+
exactly so a new door cannot quietly regress to a re-read. The definition really did
|
| 946 |
+
change (the batch is cleared when nothing is left to retry), so the panel MUST be
|
| 947 |
+
corrected; this reads ONE automation to learn it instead of the whole rail payload.
|
| 948 |
+
β The read is deliberately AFTER the outcome message is set: a send that worked followed
|
| 949 |
+
by a read that failed is still a send that worked, and the person must be told so. */
|
| 950 |
+
const { automation: fresh } = await getAutomation(automation.id);
|
| 951 |
+
await onSaved(fresh.id, fresh);
|
| 952 |
+
} catch (e) {
|
| 953 |
+
setProblem(e instanceof AutomationError ? e.message : "Those statements were not sent.");
|
| 954 |
+
} finally {
|
| 955 |
+
setSending(false);
|
| 956 |
+
}
|
| 957 |
+
};
|
| 958 |
+
|
| 959 |
const openDrill = async (entry: RunEntry) => {
|
| 960 |
if (drillFor === entry.ts) {
|
| 961 |
setDrill(null);
|
|
|
|
| 1507 |
</div>
|
| 1508 |
) : null}
|
| 1509 |
|
| 1510 |
+
{/*
|
| 1511 |
+
ββ WAVE 35 Β· T36 / OWNER RULING R10 β "N STATEMENTS READY", AND THE ONLY SEND CONTROL.
|
| 1512 |
+
Owner item 14 moves Statements out of Settings and into the agent; R10 decides the shape:
|
| 1513 |
+
the run assembles and PARKS, and a person clicks Send.
|
| 1514 |
+
|
| 1515 |
+
β THE PANEL IS HERE, ABOVE THE WORK AREA, BECAUSE IT IS THE ONE THING THAT NEEDS DOING.
|
| 1516 |
+
The wave-22 board would have been the natural home and it does not exist (wave 27 R3
|
| 1517 |
+
deleted it, and its reason was that it wrote machine columns into a customer's own table).
|
| 1518 |
+
This is the first thing on the agent instead, which is where the eye already is.
|
| 1519 |
+
|
| 1520 |
+
β IT RENDERS OFF `parked`, NOT off `batch`. The count arrives with the list payload, so
|
| 1521 |
+
the panel is on screen at first paint; the per-customer list fills in behind it. Gating
|
| 1522 |
+
the whole panel on the second fetch would make the thing a person must act on the thing
|
| 1523 |
+
that appears last.
|
| 1524 |
+
*/}
|
| 1525 |
+
{parked && parked.count > 0 ? (
|
| 1526 |
+
<section className="auto-batch" aria-label="Statements waiting for review">
|
| 1527 |
+
<div className="auto-batch-head">
|
| 1528 |
+
<span className="auto-batch-count">
|
| 1529 |
+
{parked.count} {parked.count === 1 ? "statement is" : "statements are"} ready
|
| 1530 |
+
</span>
|
| 1531 |
+
{/* β THE SENTENCE IS NOT DECORATION. A person about to email real customers must be
|
| 1532 |
+
told, before they click, that nothing has gone out yet. */}
|
| 1533 |
+
<span className="auto-batch-note">
|
| 1534 |
+
Nothing has been sent. Review the list, then release them.
|
| 1535 |
+
</span>
|
| 1536 |
+
</div>
|
| 1537 |
+
{batch && batch.safeMode ? (
|
| 1538 |
+
/* β SAFE_MODE IS REPORTED, NEVER DECIDED, HERE β the data layer owns it
|
| 1539 |
+
(`collections_send.queue_statement` raises inside the send). Saying so on the
|
| 1540 |
+
button's own panel is what stops a test send reading as a delivery failure. */
|
| 1541 |
+
<p className="auto-batch-safe" role="status">
|
| 1542 |
+
The email guardrail is on, so these go to the allow list rather than to customers.
|
| 1543 |
+
</p>
|
| 1544 |
+
) : null}
|
| 1545 |
+
{(parked.notes || []).map((n) => (
|
| 1546 |
+
<p className="auto-batch-line" key={n}>{n}</p>
|
| 1547 |
+
))}
|
| 1548 |
+
{batch && batch.items.length ? (
|
| 1549 |
+
<ul className="auto-batch-list">
|
| 1550 |
+
{batch.items.slice(0, 8).map((it) => (
|
| 1551 |
+
<li key={it.customer}>
|
| 1552 |
+
<span className="auto-batch-cust">{it.customer}</span>
|
| 1553 |
+
<span className="auto-batch-to">{it.to}</span>
|
| 1554 |
+
</li>
|
| 1555 |
+
))}
|
| 1556 |
+
{batch.items.length > 8 ? (
|
| 1557 |
+
<li className="auto-batch-more">
|
| 1558 |
+
and {batch.items.length - 8} more
|
| 1559 |
+
</li>
|
| 1560 |
+
) : null}
|
| 1561 |
+
</ul>
|
| 1562 |
+
) : null}
|
| 1563 |
+
<div className="auto-batch-actions">
|
| 1564 |
+
<button
|
| 1565 |
+
type="button"
|
| 1566 |
+
className="auto-btn is-primary"
|
| 1567 |
+
disabled={sending}
|
| 1568 |
+
onClick={() => void releaseBatch()}
|
| 1569 |
+
>
|
| 1570 |
+
{sending ? "Sendingβ¦" : `Send ${parked.count}`}
|
| 1571 |
+
</button>
|
| 1572 |
+
</div>
|
| 1573 |
+
</section>
|
| 1574 |
+
) : null}
|
| 1575 |
+
|
| 1576 |
<div className="auto-work">
|
| 1577 |
{/*
|
| 1578 |
β THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be
|
web/src/automation/automation.css
CHANGED
|
@@ -174,3 +174,71 @@
|
|
| 174 |
outline: 2px solid var(--lp-blue-deep);
|
| 175 |
outline-offset: 1px;
|
| 176 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
outline: 2px solid var(--lp-blue-deep);
|
| 175 |
outline-offset: 1px;
|
| 176 |
}
|
| 177 |
+
|
| 178 |
+
/* ββ the parked statements batch (W35-T36 / owner ruling R10) βββββββββββββββ */
|
| 179 |
+
|
| 180 |
+
/* ββ "N STATEMENTS ARE READY", with the ONLY Send control in the product.
|
| 181 |
+
It sits above `.auto-work`, in the banner band, because it is the one thing on this agent that
|
| 182 |
+
needs a person β R10's whole shape is that the run assembles and PARKS, and somebody releases.
|
| 183 |
+
|
| 184 |
+
β IT IS DELIBERATELY NOT A WARNING COLOUR. A parked batch is the system working as designed, and
|
| 185 |
+
painting it amber would teach people to dismiss the panel that guards a real mail send. It is
|
| 186 |
+
the blue tint every other "here is something to look at" surface uses. */
|
| 187 |
+
.auto-batch {
|
| 188 |
+
flex: 0 0 auto;
|
| 189 |
+
margin: 12px 24px 0;
|
| 190 |
+
padding: 12px 14px;
|
| 191 |
+
border: 1px solid var(--lp-blue);
|
| 192 |
+
border-radius: var(--lp-r-md);
|
| 193 |
+
background: var(--lp-blue-tint);
|
| 194 |
+
font-size: var(--lp-fs-sm);
|
| 195 |
+
}
|
| 196 |
+
.auto-batch-head {
|
| 197 |
+
display: flex;
|
| 198 |
+
align-items: baseline;
|
| 199 |
+
flex-wrap: wrap;
|
| 200 |
+
gap: 4px 10px;
|
| 201 |
+
}
|
| 202 |
+
.auto-batch-count {
|
| 203 |
+
font-weight: 650;
|
| 204 |
+
color: var(--lp-ink);
|
| 205 |
+
}
|
| 206 |
+
.auto-batch-note { color: var(--lp-muted); }
|
| 207 |
+
|
| 208 |
+
/* The guardrail line. Same size as a note, because it IS one: the data layer decides, this
|
| 209 |
+
reports (R6's second sentence). */
|
| 210 |
+
.auto-batch-safe,
|
| 211 |
+
.auto-batch-line {
|
| 212 |
+
margin: 6px 0 0;
|
| 213 |
+
color: var(--lp-muted);
|
| 214 |
+
font-size: var(--lp-fs-xs);
|
| 215 |
+
line-height: 1.5;
|
| 216 |
+
max-width: 70ch;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* Who is about to be emailed. β Bounded in the MARKUP at 8 rows with an "and N more" line rather
|
| 220 |
+
than scrolled here: a 200-row list inside a banner pushes the work area off the screen, and the
|
| 221 |
+
panel's job is to say who and how many, not to be the worklist. */
|
| 222 |
+
.auto-batch-list {
|
| 223 |
+
margin: 8px 0 0;
|
| 224 |
+
padding: 0;
|
| 225 |
+
list-style: none;
|
| 226 |
+
display: flex;
|
| 227 |
+
flex-direction: column;
|
| 228 |
+
gap: 2px;
|
| 229 |
+
font-size: var(--lp-fs-xs);
|
| 230 |
+
}
|
| 231 |
+
.auto-batch-list li {
|
| 232 |
+
display: flex;
|
| 233 |
+
gap: 10px;
|
| 234 |
+
align-items: baseline;
|
| 235 |
+
}
|
| 236 |
+
.auto-batch-cust { color: var(--lp-ink); }
|
| 237 |
+
.auto-batch-to { color: var(--lp-muted); overflow-wrap: anywhere; }
|
| 238 |
+
.auto-batch-more { color: var(--lp-muted); }
|
| 239 |
+
|
| 240 |
+
.auto-batch-actions {
|
| 241 |
+
margin-top: 10px;
|
| 242 |
+
display: flex;
|
| 243 |
+
gap: 8px;
|
| 244 |
+
}
|
web/src/automation/automationApi.ts
CHANGED
|
@@ -14,7 +14,14 @@
|
|
| 14 |
// of the preset list is a list that can offer a schedule the server rejects,
|
| 15 |
// and the user would see the refusal as "it just doesn't save".
|
| 16 |
// ---------------------------------------------------------------------------
|
| 17 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
/**
|
| 20 |
* β WAVE 24 / R6 β `plain` IS A REAL KIND, and it is the default for every new automation.
|
|
@@ -194,6 +201,24 @@ export interface Automation {
|
|
| 194 |
* full and the field should go β the difference between the two cases is entirely that ratchet.
|
| 195 |
*/
|
| 196 |
awaitingResults: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
/**
|
| 198 |
* ββ WAVE 32 Β· T45 (owner item 10) β WHICH ACTIONS CANNOT RUN, AND WHAT EACH ONE IS MISSING.
|
| 199 |
*
|
|
@@ -1009,8 +1034,62 @@ export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }
|
|
| 1009 |
return send<{ tables: UserTable[] }>("/automations/tables", { signal: abort });
|
| 1010 |
}
|
| 1011 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
export function createAutomation(body: unknown): Promise<{ automation: Automation }> {
|
| 1013 |
-
return send
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
/**
|
|
@@ -1077,14 +1156,29 @@ export function patchAutomation(
|
|
| 1077 |
id: string,
|
| 1078 |
body: unknown
|
| 1079 |
): Promise<{ automation: Automation }> {
|
| 1080 |
-
return send(`/automations/${encodeURIComponent(id)}`, {
|
| 1081 |
method: "PATCH",
|
| 1082 |
body: JSON.stringify(body),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
});
|
| 1084 |
}
|
| 1085 |
|
| 1086 |
export function deleteAutomation(id: string): Promise<{ deleted: string }> {
|
| 1087 |
-
return send(`/automations/${encodeURIComponent(id)}`, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
}
|
| 1089 |
|
| 1090 |
export function runAutomation(id: string): Promise<{ started: string }> {
|
|
@@ -1118,6 +1212,48 @@ export function runRows(id: string): Promise<RunRows> {
|
|
| 1118 |
return send(`/automations/${encodeURIComponent(id)}/rows`);
|
| 1119 |
}
|
| 1120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
/** Is a connector connected for THIS user? (C5 β per-user keychain slots, R7's seam.) */
|
| 1122 |
export function oauthStatus(abort?: AbortSignal): Promise<OAuthStatus> {
|
| 1123 |
return send("/oauth/status", { signal: abort });
|
|
|
|
| 14 |
// of the preset list is a list that can offer a schedule the server rejects,
|
| 15 |
// and the user would see the refusal as "it just doesn't save".
|
| 16 |
// ---------------------------------------------------------------------------
|
| 17 |
+
import {
|
| 18 |
+
AGENTS_CHANGED,
|
| 19 |
+
API_V1,
|
| 20 |
+
CREDENTIALS,
|
| 21 |
+
UNAUTHORIZED_EVENT,
|
| 22 |
+
checkTenant,
|
| 23 |
+
signal,
|
| 24 |
+
} from "../apiContract";
|
| 25 |
|
| 26 |
/**
|
| 27 |
* β WAVE 24 / R6 β `plain` IS A REAL KIND, and it is the default for every new automation.
|
|
|
|
| 201 |
* full and the field should go β the difference between the two cases is entirely that ratchet.
|
| 202 |
*/
|
| 203 |
awaitingResults: boolean;
|
| 204 |
+
/**
|
| 205 |
+
* ββ WAVE 35 Β· T36 / OWNER RULING R10 β A BATCH OF STATEMENTS IS ASSEMBLED AND WAITING FOR A
|
| 206 |
+
* HUMAN CLICK. `null` when nothing is parked, which is every automation but a statements agent
|
| 207 |
+
* that has run.
|
| 208 |
+
*
|
| 209 |
+
* β `null` AND NOT AN EMPTY OBJECT, deliberately: "no batch" and "a batch of zero" are
|
| 210 |
+
* different facts and only one of them is worth putting a panel on screen for.
|
| 211 |
+
*
|
| 212 |
+
* β COUNT AND NOTES ONLY β the rendered mail is NOT here. This type rides on the automations
|
| 213 |
+
* LIST, which every rail paint reads, and a 200-statement batch carries 200 HTML documents.
|
| 214 |
+
* `GET /admin/statements/agent/{id}` serves the per-customer detail when somebody opens it.
|
| 215 |
+
*
|
| 216 |
+
* β OPTIONAL rather than required, and this is the ONE place that is right in this file: every
|
| 217 |
+
* automation that is not a statements agent legitimately has no such key, so requiring it would
|
| 218 |
+
* make the common case the exception. Contrast `awaitingResults` and `unconfigured` above, which
|
| 219 |
+
* are facts about EVERY automation and are required for exactly that reason.
|
| 220 |
+
*/
|
| 221 |
+
pendingStatements?: { count: number; ts: string; notes: string[] } | null;
|
| 222 |
/**
|
| 223 |
* ββ WAVE 32 Β· T45 (owner item 10) β WHICH ACTIONS CANNOT RUN, AND WHAT EACH ONE IS MISSING.
|
| 224 |
*
|
|
|
|
| 1034 |
return send<{ tables: UserTable[] }>("/automations/tables", { signal: abort });
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
+
/**
|
| 1038 |
+
* ββ WAVE 35 Β· T33 / WIRING W6 (owner item 11) β "WHEN I DELETE AN AGENT, IT DOESN'T SYNC TO HOME".
|
| 1039 |
+
*
|
| 1040 |
+
* β EMITTED FROM THIS MODULE, NOT FROM `AutomationSurface`, AND THAT IS THE WHOLE DESIGN CHOICE.
|
| 1041 |
+
* Every mutation of an agent passes through one of the three doors below, so this is the ONE choke
|
| 1042 |
+
* point a fourth caller cannot forget. The surface has three call sites for the same three writes
|
| 1043 |
+
* and a fourth would arrive with no dispatch and no type error β the exact shape of the defect
|
| 1044 |
+
* being fixed, which is two readers of one fact and only one of them told.
|
| 1045 |
+
*
|
| 1046 |
+
* β THE CONSTANT, NEVER THE STRING (A's `NOTE A-9`): a second spelling is a dispatch into an empty
|
| 1047 |
+
* room, and TypeScript cannot see it. β And NO `detail`: this means "your copy is stale", not
|
| 1048 |
+
* "here is the new list" β a payload would be a third copy of one fact.
|
| 1049 |
+
*
|
| 1050 |
+
* β FIRED ON SUCCESS ONLY. A failed write changed nothing, and telling the frame to re-read after
|
| 1051 |
+
* a 400 would spend a request to learn the list it already has.
|
| 1052 |
+
*/
|
| 1053 |
+
function agentsChanged(): void {
|
| 1054 |
+
// `window` is always present in this bundle; the guard is for the gate's module-level import,
|
| 1055 |
+
// which evaluates this file outside a DOM.
|
| 1056 |
+
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(AGENTS_CHANGED));
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
/**
|
| 1060 |
+
* ββ WAVE 35 Β· T33 β DROP ONE ROW FROM THE MEMO.
|
| 1061 |
+
*
|
| 1062 |
+
* β THE HOLE THIS FILLS, AND IT IS A REAL ONE: `rememberAutomation` maps and appends but has NEVER
|
| 1063 |
+
* REMOVED. So after a delete the memo is correct only because `AutomationSurface.onDeleted` happens
|
| 1064 |
+
* to call `load()` β a CONVENTION, not a structure. Any future delete path that skips the reload
|
| 1065 |
+
* would leave `cachedAutomations()` seeding the next mount with a row the server no longer has: a
|
| 1066 |
+
* deleted agent reappearing on navigation, which is owner item 11 wearing a different coat.
|
| 1067 |
+
*
|
| 1068 |
+
* β AND IT IS AN EVICTION, NOT `forgetAutomations()`. Dropping the WHOLE memo would force
|
| 1069 |
+
* `data === null` on the next visit to Agents β the cold skeleton W29-T01 exists to prevent and
|
| 1070 |
+
* that wave-31's `rememberAutomation` spends thirty lines of rationale avoiding. One row leaves;
|
| 1071 |
+
* the rest of the paint accelerator stays.
|
| 1072 |
+
* β `at` is NOT refreshed, for the same reason `rememberAutomation` does not touch it: removing a
|
| 1073 |
+
* row does not make the list newly fetched.
|
| 1074 |
+
*/
|
| 1075 |
+
export function forgetAutomation(id: string): AutomationList | null {
|
| 1076 |
+
if (!listMemo || !id) return null;
|
| 1077 |
+
const prev = listMemo.data;
|
| 1078 |
+
const kept = prev.automations.filter((a) => a.id !== id);
|
| 1079 |
+
if (kept.length === prev.automations.length) return null;
|
| 1080 |
+
const data: AutomationList = { ...prev, automations: kept };
|
| 1081 |
+
listMemo = { at: listMemo.at, data };
|
| 1082 |
+
return data;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
export function createAutomation(body: unknown): Promise<{ automation: Automation }> {
|
| 1086 |
+
return send<{ automation: Automation }>("/automations", {
|
| 1087 |
+
method: "POST",
|
| 1088 |
+
body: JSON.stringify(body),
|
| 1089 |
+
}).then((r) => {
|
| 1090 |
+
agentsChanged();
|
| 1091 |
+
return r;
|
| 1092 |
+
});
|
| 1093 |
}
|
| 1094 |
|
| 1095 |
/**
|
|
|
|
| 1156 |
id: string,
|
| 1157 |
body: unknown
|
| 1158 |
): Promise<{ automation: Automation }> {
|
| 1159 |
+
return send<{ automation: Automation }>(`/automations/${encodeURIComponent(id)}`, {
|
| 1160 |
method: "PATCH",
|
| 1161 |
body: JSON.stringify(body),
|
| 1162 |
+
}).then((r) => {
|
| 1163 |
+
// β A RENAME IS THE CASE THAT MAKES THIS NECESSARY (owner item 11 says "fix this for everything
|
| 1164 |
+
// we display twice in Home"): Home draws the agent's NAME, so a patch that only the module
|
| 1165 |
+
// hears leaves the tile reading the old one. A toggle emits too β harmless, and cheaper than a
|
| 1166 |
+
// rule about which fields Home happens to draw today.
|
| 1167 |
+
agentsChanged();
|
| 1168 |
+
return r;
|
| 1169 |
});
|
| 1170 |
}
|
| 1171 |
|
| 1172 |
export function deleteAutomation(id: string): Promise<{ deleted: string }> {
|
| 1173 |
+
return send<{ deleted: string }>(`/automations/${encodeURIComponent(id)}`, {
|
| 1174 |
+
method: "DELETE",
|
| 1175 |
+
}).then((r) => {
|
| 1176 |
+
// BOTH halves, and neither is the other's substitute: the eviction keeps THIS module's memo
|
| 1177 |
+
// honest whoever deleted the row, and the event tells the FRAME its separate copy is stale.
|
| 1178 |
+
forgetAutomation(id);
|
| 1179 |
+
agentsChanged();
|
| 1180 |
+
return r;
|
| 1181 |
+
});
|
| 1182 |
}
|
| 1183 |
|
| 1184 |
export function runAutomation(id: string): Promise<{ started: string }> {
|
|
|
|
| 1212 |
return send(`/automations/${encodeURIComponent(id)}/rows`);
|
| 1213 |
}
|
| 1214 |
|
| 1215 |
+
/** One parked statement, as the review list shows it. The rendered mail is NOT here. */
|
| 1216 |
+
export type StatementItem = {
|
| 1217 |
+
customer: string;
|
| 1218 |
+
to: string;
|
| 1219 |
+
subject: string;
|
| 1220 |
+
tier: string;
|
| 1221 |
+
overdue?: number;
|
| 1222 |
+
};
|
| 1223 |
+
|
| 1224 |
+
export type StatementBatch = {
|
| 1225 |
+
ts: string;
|
| 1226 |
+
count: number;
|
| 1227 |
+
notes: string[];
|
| 1228 |
+
items: StatementItem[];
|
| 1229 |
+
safeMode: boolean;
|
| 1230 |
+
};
|
| 1231 |
+
|
| 1232 |
+
/**
|
| 1233 |
+
* ββ WAVE 35 Β· T36 / R10 β the parked batch, per agent.
|
| 1234 |
+
*
|
| 1235 |
+
* β THESE TWO CALLS DO NOT GO TO `/automations`. The statements doors live on
|
| 1236 |
+
* `routes_statements`, behind `admin_gate` + the Royal Imports tenant gate, because that is where
|
| 1237 |
+
* the send guardrails already are and R10's whole point is that they do not move.
|
| 1238 |
+
*/
|
| 1239 |
+
export function statementBatch(autoId: string, abort?: AbortSignal):
|
| 1240 |
+
Promise<{ pending: StatementBatch | null }> {
|
| 1241 |
+
return send(`/admin/statements/agent/${encodeURIComponent(autoId)}`, { signal: abort });
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
/**
|
| 1245 |
+
* β THE CLICK R10 REQUIRES, and the ONLY caller of the send door in the whole client. Nothing
|
| 1246 |
+
* schedules this, nothing retries it, and the agent's own run cannot reach it: a person opens the
|
| 1247 |
+
* batch and presses the button. `customers` may only NARROW what was parked.
|
| 1248 |
+
*/
|
| 1249 |
+
export function sendStatementBatch(autoId: string, customers?: string[]):
|
| 1250 |
+
Promise<{ sent: unknown[]; failed: unknown[]; skipped: unknown[]; cleared: boolean }> {
|
| 1251 |
+
return send(`/admin/statements/agent/${encodeURIComponent(autoId)}/send`, {
|
| 1252 |
+
method: "POST",
|
| 1253 |
+
body: JSON.stringify(customers && customers.length ? { customers } : {}),
|
| 1254 |
+
});
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
/** Is a connector connected for THIS user? (C5 β per-user keychain slots, R7's seam.) */
|
| 1258 |
export function oauthStatus(abort?: AbortSignal): Promise<OAuthStatus> {
|
| 1259 |
return send("/oauth/status", { signal: abort });
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -33,9 +33,18 @@ import { acceptsQueryPreview, routeQueryViewMutation } from "./queryPreview";
|
|
| 33 |
// only its own source text could otherwise be checked.
|
| 34 |
import { WINDOW_ROWS } from "./apiBridge";
|
| 35 |
import {
|
| 36 |
-
EMPTY_WINDOW_PREDICATE, limitSummary,
|
| 37 |
-
windowedFoldNote, windowPredicateKey,
|
| 38 |
} from "./counts";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
import { defaultViewConfig, useGridColumns } from "./useGridColumns";
|
| 40 |
import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| 41 |
unresolvedConditions, useVisibleRows } from "./useVisibleRows";
|
|
@@ -114,7 +123,8 @@ import {
|
|
| 114 |
planFieldPaste,
|
| 115 |
pasteRowCount,
|
| 116 |
} from "./clipboard";
|
| 117 |
-
import { cellTipText, expandButtonRect,
|
|
|
|
| 118 |
headerMarkSizes, tipLeft } from "./overlayPlacement";
|
| 119 |
import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface";
|
| 120 |
import type { AnchorRect } from "./OverlaySurface";
|
|
@@ -378,6 +388,32 @@ function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedV
|
|
| 378 |
: [];
|
| 379 |
config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null;
|
| 380 |
if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
return {
|
| 382 |
id: binding.artifactId,
|
| 383 |
name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label,
|
|
@@ -747,7 +783,31 @@ function CustomerGridSurface({
|
|
| 747 |
onEmbeddedSelectionChange,
|
| 748 |
}: CustomerGridProps = {}) {
|
| 749 |
const isQueryPreview = queryBinding !== undefined;
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
// Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
|
| 752 |
const topic = topicForScope(scope);
|
| 753 |
const {
|
|
@@ -760,9 +820,15 @@ function CustomerGridSurface({
|
|
| 760 |
patchOverlay,
|
| 761 |
requestWindow,
|
| 762 |
} = useCustomerData(scope, {
|
| 763 |
-
bindSurface
|
| 764 |
-
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
});
|
| 767 |
const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
|
| 768 |
/**
|
|
@@ -862,9 +928,26 @@ function CustomerGridSurface({
|
|
| 862 |
* β EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone
|
| 863 |
* alerts on, and a second fetch per relation cell would be a workspace read per click.
|
| 864 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
const [alerted, setAlerted] = useState<string[]>([]);
|
| 866 |
useEffect(() => {
|
| 867 |
-
|
|
|
|
|
|
|
|
|
|
| 868 |
let live = true;
|
| 869 |
const pull = () => {
|
| 870 |
void fetchAlerts().then((r) => {
|
|
@@ -878,7 +961,7 @@ function CustomerGridSurface({
|
|
| 878 |
live = false;
|
| 879 |
window.removeEventListener("focus", pull);
|
| 880 |
};
|
| 881 |
-
}, [
|
| 882 |
|
| 883 |
const [fields, setFields] = useState<Field[]>([]);
|
| 884 |
const [views, setViews] = useState<SavedView[]>([]);
|
|
@@ -901,6 +984,15 @@ function CustomerGridSurface({
|
|
| 901 |
const [expandAt, setExpandAt] = useState<
|
| 902 |
{ pid: number; x: number; y: number; size: number } | null
|
| 903 |
>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
const [detailPid, setDetailPid] = useState<number | null>(null);
|
| 905 |
const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
|
| 906 |
/** Open choice list for a `select` / `user` cell β see onCellClicked. */
|
|
@@ -1056,11 +1148,32 @@ function CustomerGridSurface({
|
|
| 1056 |
useEffect(() => {
|
| 1057 |
if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
|
| 1058 |
if (isQueryPreview && queryBinding) {
|
| 1059 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
setFields(payloadFields);
|
| 1061 |
-
setViews([
|
| 1062 |
-
setActiveViewId(
|
| 1063 |
-
setConfig(
|
| 1064 |
setWorkspaceReady(true);
|
| 1065 |
initializedKey.current = storageKey;
|
| 1066 |
return;
|
|
@@ -1158,7 +1271,7 @@ function CustomerGridSurface({
|
|
| 1158 |
* the config the client is actually filtering with, and sending anything else would ask the
|
| 1159 |
* host to resolve a question nobody on screen is asking.
|
| 1160 |
*/
|
| 1161 |
-
if (
|
| 1162 |
const stamped = { ...(local?.reemitted ?? {}) };
|
| 1163 |
for (const view of initialViews) {
|
| 1164 |
const key = reemit.get(view.id);
|
|
@@ -1173,10 +1286,15 @@ function CustomerGridSurface({
|
|
| 1173 |
} else {
|
| 1174 |
reemittedRef.current = { ...(local?.reemitted ?? {}) };
|
| 1175 |
}
|
| 1176 |
-
}, [payload, payloadFields, storageKey,
|
| 1177 |
|
| 1178 |
useEffect(() => {
|
| 1179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1180 |
writeLocal(storageKey, {
|
| 1181 |
fields,
|
| 1182 |
views,
|
|
@@ -1193,7 +1311,7 @@ function CustomerGridSurface({
|
|
| 1193 |
// drop a view this browser created seconds ago.
|
| 1194 |
viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
|
| 1195 |
});
|
| 1196 |
-
}, [workspaceReady, storageKey, fields, views, activeViewId,
|
| 1197 |
|
| 1198 |
/**
|
| 1199 |
* β THE LIVE WORKSPACE (owner report, 2026-08-04) β what has APPEARED since we mounted.
|
|
@@ -1218,7 +1336,9 @@ function CustomerGridSurface({
|
|
| 1218 |
*/
|
| 1219 |
const hostWorkspaceViews = payload?.workspace?.views;
|
| 1220 |
useEffect(() => {
|
| 1221 |
-
|
|
|
|
|
|
|
| 1222 |
const now = Date.now();
|
| 1223 |
const nextFields = adoptNewFields(
|
| 1224 |
fields, payloadFields, fieldStampsRef.current.deleted, now
|
|
@@ -1228,7 +1348,7 @@ function CustomerGridSurface({
|
|
| 1228 |
adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
|
| 1229 |
(config) => normalizeConfig(config, nextFields))
|
| 1230 |
);
|
| 1231 |
-
}, [workspaceReady, hostWorkspaceViews, payloadFields, fields,
|
| 1232 |
|
| 1233 |
/** Item 3c β stamp a def write / a delete. Pruned at every touch so the persisted blob
|
| 1234 |
* stays a recent window, never an archive. */
|
|
@@ -1293,7 +1413,7 @@ function CustomerGridSurface({
|
|
| 1293 |
* this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
|
| 1294 |
* row.
|
| 1295 |
*/
|
| 1296 |
-
const isUserTable = !
|
| 1297 |
const recordsMutable = payload?.recordsMutable !== false;
|
| 1298 |
const canMutateRecords = isUserTable && recordsMutable;
|
| 1299 |
/**
|
|
@@ -1451,7 +1571,8 @@ function CustomerGridSurface({
|
|
| 1451 |
|
| 1452 |
// Airtable behavior: configuration changes to the active view autosave.
|
| 1453 |
useEffect(() => {
|
| 1454 |
-
|
|
|
|
| 1455 |
const active = views.find((view) => view.id === activeViewId);
|
| 1456 |
if (!active || sameConfig(active.config, config)) {
|
| 1457 |
setSaveState("saved");
|
|
@@ -1466,14 +1587,31 @@ function CustomerGridSurface({
|
|
| 1466 |
);
|
| 1467 |
viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
|
| 1468 |
Date.now());
|
| 1469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1470 |
setSaveState("saved");
|
| 1471 |
saveTimer.current = null;
|
| 1472 |
}, 420);
|
| 1473 |
return () => {
|
| 1474 |
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
|
| 1475 |
};
|
| 1476 |
-
}, [workspaceReady, activeViewId, config, views,
|
| 1477 |
|
| 1478 |
// Numeric dimensions force a glide relayout when either the component frame
|
| 1479 |
// or Streamlit's main column changes width (notably sidebar collapse).
|
|
@@ -1504,7 +1642,7 @@ function CustomerGridSurface({
|
|
| 1504 |
const mode = tableMode(payload?.counts);
|
| 1505 |
const serverWindowed = mode === "server-windowed";
|
| 1506 |
// Owner items 4+6 β the Cohort page's grid renders without the Views sidebar.
|
| 1507 |
-
const hideViews =
|
| 1508 |
// Wave-6 item 10 β how this view displays. A WINDOWED table is always the grid: list/
|
| 1509 |
// calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
|
| 1510 |
// the same reason grouping is off there).
|
|
@@ -1642,8 +1780,8 @@ function CustomerGridSurface({
|
|
| 1642 |
// permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
|
| 1643 |
const viewer = payload?.viewer;
|
| 1644 |
const canEditField = useCallback(
|
| 1645 |
-
(f: Field): boolean => !
|
| 1646 |
-
[
|
| 1647 |
);
|
| 1648 |
const unresolvedCount = useMemo(
|
| 1649 |
() => unresolvedConditions(config.filters, { cohortSets, today }),
|
|
@@ -1848,6 +1986,37 @@ function CustomerGridSurface({
|
|
| 1848 |
() => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
|
| 1849 |
[serverWindowed, payload?.counts?.matched]
|
| 1850 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1851 |
const displayRows = useMemo(() => {
|
| 1852 |
const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
|
| 1853 |
// β APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β and it is the
|
|
@@ -2553,9 +2722,19 @@ function CustomerGridSurface({
|
|
| 2553 |
// and a `position: fixed` button with no clamp would paint over the chrome on a row
|
| 2554 |
// nobody can see. The horizontal legs are now defence in depth (a column drag, a box
|
| 2555 |
// narrower than the frozen strip) rather than the everyday case.
|
| 2556 |
-
const
|
| 2557 |
-
|
| 2558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2559 |
// Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over
|
| 2560 |
// the same record at a new y, and a button that keeps the pid but not the position
|
| 2561 |
// floats over the wrong row. Referential stability is preserved by comparing values,
|
|
@@ -3172,26 +3351,40 @@ function CustomerGridSurface({
|
|
| 3172 |
visibleCols, fieldByKey, canEditField]
|
| 3173 |
);
|
| 3174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3175 |
const persistView = useCallback((view: SavedView) => {
|
| 3176 |
-
if (queryBinding) {
|
| 3177 |
-
const routed = routeQueryViewMutation(queryBinding, scope, {
|
| 3178 |
-
id: eventId("query-view-update"),
|
| 3179 |
-
type: "view_upsert",
|
| 3180 |
-
view: view as unknown as Record<string, unknown>,
|
| 3181 |
-
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
| 3182 |
-
if (routed.channel === "refused")
|
| 3183 |
-
signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot write a source view.");
|
| 3184 |
-
return;
|
| 3185 |
-
}
|
| 3186 |
setViews((current) => {
|
| 3187 |
const found = current.some((item) => item.id === view.id);
|
| 3188 |
return found
|
| 3189 |
? current.map((item) => (item.id === view.id ? view : item))
|
| 3190 |
: [...current, view];
|
| 3191 |
});
|
| 3192 |
-
routeQueryViewMutation(
|
| 3193 |
-
id: eventId("view"
|
|
|
|
|
|
|
| 3194 |
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3195 |
}, [queryBinding, scope]);
|
| 3196 |
|
| 3197 |
/**
|
|
@@ -3231,7 +3424,19 @@ function CustomerGridSurface({
|
|
| 3231 |
const current = views.find((view) => view.id === activeViewId);
|
| 3232 |
if (current && !sameConfig(current.config, config))
|
| 3233 |
persistView({ ...current, config });
|
| 3234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3235 |
if (!next) return;
|
| 3236 |
setActiveViewId(id);
|
| 3237 |
setConfig(normalizeConfig(next.config, fields));
|
|
@@ -3246,7 +3451,7 @@ function CustomerGridSurface({
|
|
| 3246 |
// Owner item 10: opening a view is "I am working now" β the frame folds its nav rail.
|
| 3247 |
signal(NAV_MINIMIZE_EVENT);
|
| 3248 |
},
|
| 3249 |
-
[views, activeViewId, config, persistView, fields, isQueryPreview]
|
| 3250 |
);
|
| 3251 |
|
| 3252 |
/**
|
|
@@ -3650,44 +3855,18 @@ function CustomerGridSurface({
|
|
| 3650 |
* order β two lists that decide "is there a number here" by different rules would drift, and
|
| 3651 |
* the drift would show as a row wearing both a count and an excuse.
|
| 3652 |
*/
|
| 3653 |
-
/*
|
| 3654 |
-
*
|
| 3655 |
-
*
|
| 3656 |
-
* Owner, verbatim: *"Let's also mark important the database, meaning that the database should
|
| 3657 |
-
* have the sum number of all the views with mark important numbers whenever a minimum of 1
|
| 3658 |
-
* view is marked important in a database."*
|
| 3659 |
-
*
|
| 3660 |
-
* β SUMMED OVER `importantIds`, NOT OVER `alertCounts`, and that is the whole correctness of
|
| 3661 |
-
* this memo. `alertCounts` is keyed by `alerted βͺ important` β a view can be in it because
|
| 3662 |
-
* somebody set a server-side ALERT on it, which is a different feature. Summing the map
|
| 3663 |
-
* wholesale would answer "alerts plus marks" under a label that says marks, and the two sets
|
| 3664 |
-
* overlap, so the error would be invisible on any table where they happen to coincide.
|
| 3665 |
*
|
| 3666 |
-
*
|
| 3667 |
-
*
|
| 3668 |
-
*
|
| 3669 |
-
*
|
| 3670 |
-
*
|
| 3671 |
*
|
| 3672 |
-
* β
|
| 3673 |
-
* views
|
| 3674 |
-
* would be a different (and more expensive) question, and would disagree with the per-view
|
| 3675 |
-
* badges the user can see and add up themselves.
|
| 3676 |
-
* β NO NEW QUERY (R8): this folds over `alertCounts`, which the grid has already computed.
|
| 3677 |
*/
|
| 3678 |
-
const importantTotal = useMemo(() => {
|
| 3679 |
-
if (!importantIds.length) return null;
|
| 3680 |
-
let sum = 0;
|
| 3681 |
-
let counted = 0;
|
| 3682 |
-
for (const id of importantIds) {
|
| 3683 |
-
const n = alertCounts[id];
|
| 3684 |
-
if (typeof n === "number") {
|
| 3685 |
-
sum += n;
|
| 3686 |
-
counted += 1;
|
| 3687 |
-
}
|
| 3688 |
-
}
|
| 3689 |
-
return { sum, counted, marked: importantIds.length };
|
| 3690 |
-
}, [importantIds, alertCounts]);
|
| 3691 |
|
| 3692 |
const countNotes = useMemo(() => {
|
| 3693 |
const out: Record<string, string> = {};
|
|
@@ -5252,7 +5431,7 @@ function CustomerGridSurface({
|
|
| 5252 |
const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
|
| 5253 |
const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
|
| 5254 |
// Item 10 β the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
|
| 5255 |
-
const modeControl = serverWindowed ||
|
| 5256 |
<ModeSwitch
|
| 5257 |
mode={displayMode}
|
| 5258 |
onMode={setDisplayMode}
|
|
@@ -5386,10 +5565,25 @@ function CustomerGridSurface({
|
|
| 5386 |
* fact about ONE surface; folding it into the state would make every consumer's
|
| 5387 |
* behaviour depend on a drag, and the drag's whole scope is which row sits where.
|
| 5388 |
*/
|
| 5389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5390 |
alertCounts={alertCounts}
|
| 5391 |
countNotes={countNotes}
|
| 5392 |
-
importantTotal={importantTotal}
|
| 5393 |
activeViewId={activeViewId}
|
| 5394 |
saveState={saveState}
|
| 5395 |
onSelect={selectView}
|
|
@@ -5655,7 +5849,16 @@ function CustomerGridSurface({
|
|
| 5655 |
// canvas, so reaching for it IS an out-of-bounds event and dismissing there would
|
| 5656 |
// unmount the control under the arriving pointer. Leaving the whole grid box is the
|
| 5657 |
// honest "you are done with this row".
|
| 5658 |
-
onMouseLeave={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5659 |
>
|
| 5660 |
{displayMode === "grid" && (
|
| 5661 |
<>
|
|
@@ -5739,7 +5942,7 @@ function CustomerGridSurface({
|
|
| 5739 |
height={gridSize.height}
|
| 5740 |
customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
|
| 5741 |
headerIcons={HEADER_ICONS}
|
| 5742 |
-
rightElement={
|
| 5743 |
// Wave-6 item 4 β the "+" of the header row: the create-field form,
|
| 5744 |
// insert-at-end.
|
| 5745 |
//
|
|
@@ -5933,7 +6136,7 @@ function CustomerGridSurface({
|
|
| 5933 |
docs={payload?.docs}
|
| 5934 |
docPayload={payload?.docPayload}
|
| 5935 |
onDocAdd={
|
| 5936 |
-
!
|
| 5937 |
? (pid, file) =>
|
| 5938 |
emitHostEvent({
|
| 5939 |
id: eventId("docadd"),
|
|
@@ -5945,13 +6148,13 @@ function CustomerGridSurface({
|
|
| 5945 |
: undefined
|
| 5946 |
}
|
| 5947 |
onDocFetch={
|
| 5948 |
-
!
|
| 5949 |
? (pid, docId) =>
|
| 5950 |
emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
|
| 5951 |
: undefined
|
| 5952 |
}
|
| 5953 |
onDocDelete={
|
| 5954 |
-
!
|
| 5955 |
? (pid, docId) =>
|
| 5956 |
emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
|
| 5957 |
: undefined
|
|
@@ -6119,6 +6322,43 @@ function CustomerGridSurface({
|
|
| 6119 |
</svg>
|
| 6120 |
</button>
|
| 6121 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6122 |
{/* Wave-5 item 6 β the header description tip. pointer-events:none + aria-hidden:
|
| 6123 |
it can never become a click target, so the control under it stays clickable
|
| 6124 |
(the tooltip-swallows-the-next-click trap, paid for once already). */}
|
|
@@ -6259,7 +6499,7 @@ function CustomerGridSurface({
|
|
| 6259 |
β The display cap and the server's limits are INDEPENDENT FACTS about one grid: one is
|
| 6260 |
how much we are painting, the other is what the server could not do. They belong in
|
| 6261 |
the same strip and neither may suppress the other. */}
|
| 6262 |
-
{(foldNote || limitNote || capabilityNote) && (
|
| 6263 |
<div className="cg-more">
|
| 6264 |
{foldNote && <span className="cg-more-note">{foldNote}</span>}
|
| 6265 |
{limitNote && (
|
|
@@ -6270,6 +6510,15 @@ function CustomerGridSurface({
|
|
| 6270 |
{capabilityNote.short}
|
| 6271 |
</span>
|
| 6272 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6273 |
</div>
|
| 6274 |
)}
|
| 6275 |
{capped && (displayMode === "grid" || displayMode === "list") && (
|
|
@@ -7037,6 +7286,13 @@ function CustomerGridSurface({
|
|
| 7037 |
fields={fields}
|
| 7038 |
record={detailRecord}
|
| 7039 |
titleKey={lockedKey}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7040 |
positionLabel={`Record ${dataPosition.toLocaleString()} of ${recordCount.toLocaleString()}`}
|
| 7041 |
canPrev={neighborExists(-1)}
|
| 7042 |
canNext={neighborExists(1)}
|
|
@@ -7057,7 +7313,7 @@ function CustomerGridSurface({
|
|
| 7057 |
// on the NEXT render via `payload.docPayload`; Documents.tsx matches it
|
| 7058 |
// to its own pending request before touching it.
|
| 7059 |
onDocAdd={
|
| 7060 |
-
!
|
| 7061 |
? (file) =>
|
| 7062 |
emitHostEvent({
|
| 7063 |
id: eventId("docadd"),
|
|
@@ -7069,7 +7325,7 @@ function CustomerGridSurface({
|
|
| 7069 |
: undefined
|
| 7070 |
}
|
| 7071 |
onDocFetch={
|
| 7072 |
-
!
|
| 7073 |
? (docId) =>
|
| 7074 |
emitHostEvent({
|
| 7075 |
id: eventId("docget"),
|
|
@@ -7080,7 +7336,7 @@ function CustomerGridSurface({
|
|
| 7080 |
: undefined
|
| 7081 |
}
|
| 7082 |
onDocDelete={
|
| 7083 |
-
!
|
| 7084 |
? (docId) =>
|
| 7085 |
emitHostEvent({
|
| 7086 |
id: eventId("docdel"),
|
|
|
|
| 33 |
// only its own source text could otherwise be checked.
|
| 34 |
import { WINDOW_ROWS } from "./apiBridge";
|
| 35 |
import {
|
| 36 |
+
EMPTY_WINDOW_PREDICATE, limitSummary, lockedRecordsNote, nextWindowOffset,
|
| 37 |
+
windowedCapabilityNote, windowedFoldNote, windowPredicateKey,
|
| 38 |
} from "./counts";
|
| 39 |
+
// β W35-T29/T30 (C5) β the record star's client, and its own stylesheet imported HERE, by the
|
| 40 |
+
// module that renders its classes. W35-T22 in this same wave was the cost of doing otherwise.
|
| 41 |
+
import { useRecordStars } from "./recordStars";
|
| 42 |
+
// β ALIASED, and the collision is worth naming: `./Stars`'s `StarIcon` is the RATING atom (a
|
| 43 |
+
// 5-point star for a 1..5 field) and this one is the record MARK. Two different meanings, one
|
| 44 |
+
// noun, already both in this file β importing the second under its own name would have shadowed
|
| 45 |
+
// the first and silently repainted every rating picker.
|
| 46 |
+
import { StarIcon as RecordStarMark } from "../ui/icons";
|
| 47 |
+
import "./rowStar.css";
|
| 48 |
import { defaultViewConfig, useGridColumns } from "./useGridColumns";
|
| 49 |
import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| 50 |
unresolvedConditions, useVisibleRows } from "./useVisibleRows";
|
|
|
|
| 123 |
planFieldPaste,
|
| 124 |
pasteRowCount,
|
| 125 |
} from "./clipboard";
|
| 126 |
+
import { cellTipText, expandButtonRect, starButtonRect,
|
| 127 |
+
GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout,
|
| 128 |
headerMarkSizes, tipLeft } from "./overlayPlacement";
|
| 129 |
import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface";
|
| 130 |
import type { AnchorRect } from "./OverlaySurface";
|
|
|
|
| 388 |
: [];
|
| 389 |
config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null;
|
| 390 |
if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec;
|
| 391 |
+
/**
|
| 392 |
+
* ββ W35-T25 β THE MEMBERS AN EDIT CAN MOVE, READ BACK FROM THE SERVER'S COPY.
|
| 393 |
+
*
|
| 394 |
+
* R2 made the spec editable and `routes_query.QUERY_EDITABLE_SPEC` is the allow-list it may
|
| 395 |
+
* move. Every one of them has to be read HERE too, or the round trip is one-directional: the
|
| 396 |
+
* edit posts, the server stores it, and the next mount rebuilds the view from the AI's members
|
| 397 |
+
* alone β so a resize would survive a reload on THIS browser (the local bucket) and vanish on
|
| 398 |
+
* any other, which is the worst of the three possible behaviours because it looks like it works.
|
| 399 |
+
*
|
| 400 |
+
* β Each is validated against the CURRENT field list, exactly like the members above: the
|
| 401 |
+
* server cleans on write, this cleans on read, and neither trusts the other's vintage.
|
| 402 |
+
*/
|
| 403 |
+
if (Array.isArray(raw.order)) {
|
| 404 |
+
const order = raw.order.filter((key): key is string => typeof key === "string" && keys.has(key));
|
| 405 |
+
if (order.length) config.order = [...order, ...config.order.filter((key) => !order.includes(key))];
|
| 406 |
+
}
|
| 407 |
+
if (raw.widths && typeof raw.widths === "object") {
|
| 408 |
+
const widths: Record<string, number> = {};
|
| 409 |
+
for (const [key, width] of Object.entries(raw.widths as Record<string, unknown>))
|
| 410 |
+
if (keys.has(key) && typeof width === "number" && width > 0) widths[key] = width;
|
| 411 |
+
config.widths = widths;
|
| 412 |
+
}
|
| 413 |
+
if (typeof raw.colorBy === "string" && keys.has(raw.colorBy)) config.colorBy = raw.colorBy;
|
| 414 |
+
if (typeof raw.rowHeightMode === "string")
|
| 415 |
+
config.rowHeightMode = raw.rowHeightMode as ViewConfig["rowHeightMode"];
|
| 416 |
+
if (typeof raw.frozenCount === "number") config.frozenCount = clampFrozenCount(raw.frozenCount);
|
| 417 |
return {
|
| 418 |
id: binding.artifactId,
|
| 419 |
name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label,
|
|
|
|
| 783 |
onEmbeddedSelectionChange,
|
| 784 |
}: CustomerGridProps = {}) {
|
| 785 |
const isQueryPreview = queryBinding !== undefined;
|
| 786 |
+
/**
|
| 787 |
+
* ββ W35-T23 (owner item 4 / R2) β **`previewReadOnly` IS GONE, AND THE SPLIT IS THE TICKET.**
|
| 788 |
+
*
|
| 789 |
+
* It used to be `embedded || isQueryPreview`: ONE flag switching off view persistence, field
|
| 790 |
+
* editing, add-row and the toolbar's right element for two surfaces that are not the same
|
| 791 |
+
* thing. Owner: *"I should be able to interact in each of the View under Query as well, exactly
|
| 792 |
+
* like how I would be able to interact with it under Database view."* R2 makes Query views
|
| 793 |
+
* live β resize, sort, group, hide, row height, export, edit a cell, add a row β **subject to
|
| 794 |
+
* the SOURCE database's own locks**, which is the predicate that does the work now.
|
| 795 |
+
*
|
| 796 |
+
* β TWO QUESTIONS, NOT ONE, AND CONFLATING THEM IS WHAT MADE THE SINGLE FLAG WRONG:
|
| 797 |
+
* Β· **`embedded`** β *may this surface WRITE anything at all?* A linked-record grid inside a
|
| 798 |
+
* modal may not, and keeps every refusal it has today. Unchanged, deliberately.
|
| 799 |
+
* Β· **`hostWorkspace`** β *does this surface own the SOURCE DATABASE's workspace?* Its views,
|
| 800 |
+
* its `storageKey`, its view rail, its alert list. **A Query surface does NOT**, and that
|
| 801 |
+
* has nothing to do with read-only: the artefact has its own single view, its own storage
|
| 802 |
+
* key (`query:<qid>`) and its own transport. Opening `includeWorkspace` for Query would
|
| 803 |
+
* load the source's view list into a Query surface β two surfaces sharing one view list,
|
| 804 |
+
* which is the trap this ticket names by name.
|
| 805 |
+
*
|
| 806 |
+
* β SO A QUERY SURFACE IS `!embedded && !hostWorkspace`: it writes, and it writes somewhere
|
| 807 |
+
* else. Any new guard added below has to answer WHICH of the two questions it is asking; if
|
| 808 |
+
* the answer is "both", it is probably asking the wrong one.
|
| 809 |
+
*/
|
| 810 |
+
const hostWorkspace = !embedded && !isQueryPreview;
|
| 811 |
// Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
|
| 812 |
const topic = topicForScope(scope);
|
| 813 |
const {
|
|
|
|
| 820 |
patchOverlay,
|
| 821 |
requestWindow,
|
| 822 |
} = useCustomerData(scope, {
|
| 823 |
+
// β `bindSurface` is the MODULE-scope write target every emitted event carries. A Query
|
| 824 |
+
// surface's cell edits belong to the SOURCE database, so it must bind β the linked-record
|
| 825 |
+
// grid must not, because its parent still owns that scope.
|
| 826 |
+
bindSurface: !embedded,
|
| 827 |
+
// β The one flag that stays on the WIDE predicate: neither surface reads the source
|
| 828 |
+
// database's own saved views.
|
| 829 |
+
includeWorkspace: hostWorkspace,
|
| 830 |
+
// `writable: false` blocks `patchOverlay`, i.e. every cell edit. R2 wants them in Query.
|
| 831 |
+
writable: !embedded,
|
| 832 |
});
|
| 833 |
const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
|
| 834 |
/**
|
|
|
|
| 928 |
* β EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone
|
| 929 |
* alerts on, and a second fetch per relation cell would be a workspace read per click.
|
| 930 |
*/
|
| 931 |
+
/**
|
| 932 |
+
* ββ W35-T29 + T30 Β· CONTRACT C5 (R5, R6) β the RECORD stars for this database.
|
| 933 |
+
*
|
| 934 |
+
* β PER USER, unlike every other star in the product. A database, an agent or a Query view is
|
| 935 |
+
* starred through `config.important` on ONE record, so starring a SHARED view stars it for
|
| 936 |
+
* everybody who can see it; a record star lives in the caller's own per-username stratum and two
|
| 937 |
+
* accounts see two different sets. One word on screen, two scopes underneath (mailbox E-3).
|
| 938 |
+
* β AND IT IS ITS OWN DOOR, NOT A VIEW WRITE (D-170): a view write on a read-through grid 409s
|
| 939 |
+
* because it materialises the pool, and this must work on `ut_odoo_gl_lines`.
|
| 940 |
+
*
|
| 941 |
+
* β OFF FOR AN EMBEDDED GRID. A linked-record picker inside a modal is not a table anybody keeps
|
| 942 |
+
* marks on, and the read would be one request per relation cell.
|
| 943 |
+
*/
|
| 944 |
+
const recordStars = useRecordStars(scope, !embedded);
|
| 945 |
const [alerted, setAlerted] = useState<string[]>([]);
|
| 946 |
useEffect(() => {
|
| 947 |
+
// `hostWorkspace`, not `embedded`: the alert badges paint on the SOURCE database's view rail,
|
| 948 |
+
// which a Query surface does not render at all (`hideViews`). Fetching them would be a
|
| 949 |
+
// workspace read whose answer has nowhere to go.
|
| 950 |
+
if (!hostWorkspace) return;
|
| 951 |
let live = true;
|
| 952 |
const pull = () => {
|
| 953 |
void fetchAlerts().then((r) => {
|
|
|
|
| 961 |
live = false;
|
| 962 |
window.removeEventListener("focus", pull);
|
| 963 |
};
|
| 964 |
+
}, [hostWorkspace, scope]);
|
| 965 |
|
| 966 |
const [fields, setFields] = useState<Field[]>([]);
|
| 967 |
const [views, setViews] = useState<SavedView[]>([]);
|
|
|
|
| 984 |
const [expandAt, setExpandAt] = useState<
|
| 985 |
{ pid: number; x: number; y: number; size: number } | null
|
| 986 |
>(null);
|
| 987 |
+
/**
|
| 988 |
+
* β W35-T29 β the same idea at the other end of the same cell: which record the hovered star
|
| 989 |
+
* belongs to, and where it sits. A separate state from `expandAt` because the two have
|
| 990 |
+
* different minimum row heights (this mark is smaller), so a compact row can carry one and not
|
| 991 |
+
* the other, and a shared state would have to pick.
|
| 992 |
+
*/
|
| 993 |
+
const [starHover, setStarHover] = useState<
|
| 994 |
+
{ pid: number; x: number; y: number; size: number } | null
|
| 995 |
+
>(null);
|
| 996 |
const [detailPid, setDetailPid] = useState<number | null>(null);
|
| 997 |
const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
|
| 998 |
/** Open choice list for a `select` / `user` cell β see onCellClicked. */
|
|
|
|
| 1148 |
useEffect(() => {
|
| 1149 |
if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
|
| 1150 |
if (isQueryPreview && queryBinding) {
|
| 1151 |
+
/**
|
| 1152 |
+
* ββ W35-T23 + T25 (R2/R3) β THE SERVER OWNS A QUERY ARTEFACT'S SPEC, AND THIS SURFACE
|
| 1153 |
+
* KEEPS NO SECOND COPY. That is the whole of "a resize survives a reload" now.
|
| 1154 |
+
*
|
| 1155 |
+
* `binding.view` IS the stored spec (`routes_query` accepts a `view_upsert` of the
|
| 1156 |
+
* artefact's own view and `queryPreviewView` reads every editable member back off it), so a
|
| 1157 |
+
* width dragged on one device is there on the next, on any device, without a local bucket.
|
| 1158 |
+
*
|
| 1159 |
+
* ββ THE FIRST DRAFT SEEDED FROM `readLocal(storageKey)` AND IT WAS WRONG IN THE WORST
|
| 1160 |
+
* SHAPE THIS REPO CATALOGUES β works, then does not, then works again. The local copy had to
|
| 1161 |
+
* be gated on `binding.edited` so a Revert was not silently undone on that device; but
|
| 1162 |
+
* `binding` is memoised on `active`, and NOTHING in the autosave path refetches the artefact
|
| 1163 |
+
* β so after the FIRST edit in a session the flag was still `false`, and navigating away and
|
| 1164 |
+
* back inside that session dropped the width while a full reload brought it back. A second
|
| 1165 |
+
* store for one spec, plus a flag to arbitrate between them, is the shape R4 forbids one
|
| 1166 |
+
* ruling over. There is one store now and nothing to arbitrate.
|
| 1167 |
+
*
|
| 1168 |
+
* β THE COST, STATED: an edit whose POST fails is lost at the next mount, and the reader is
|
| 1169 |
+
* TOLD (the autosave toasts the failure). That is the honest trade against a local copy that
|
| 1170 |
+
* can disagree with the server about what the artefact is.
|
| 1171 |
+
*/
|
| 1172 |
+
const view = queryPreviewView(queryBinding, payloadFields);
|
| 1173 |
setFields(payloadFields);
|
| 1174 |
+
setViews([view]);
|
| 1175 |
+
setActiveViewId(view.id);
|
| 1176 |
+
setConfig(view.config);
|
| 1177 |
setWorkspaceReady(true);
|
| 1178 |
initializedKey.current = storageKey;
|
| 1179 |
return;
|
|
|
|
| 1271 |
* the config the client is actually filtering with, and sending anything else would ask the
|
| 1272 |
* host to resolve a question nobody on screen is asking.
|
| 1273 |
*/
|
| 1274 |
+
if (hostWorkspace && reemit.size > 0) {
|
| 1275 |
const stamped = { ...(local?.reemitted ?? {}) };
|
| 1276 |
for (const view of initialViews) {
|
| 1277 |
const key = reemit.get(view.id);
|
|
|
|
| 1286 |
} else {
|
| 1287 |
reemittedRef.current = { ...(local?.reemitted ?? {}) };
|
| 1288 |
}
|
| 1289 |
+
}, [payload, payloadFields, storageKey, hostWorkspace, scope, isQueryPreview, queryBinding]);
|
| 1290 |
|
| 1291 |
useEffect(() => {
|
| 1292 |
+
// β `hostWorkspace`, and a Query surface is deliberately EXCLUDED (W35-T23 + T25). Its spec
|
| 1293 |
+
// lives on the server, which is the only copy β writing a second one here would be a store
|
| 1294 |
+
// with no reader, and gating a read on which of the two is fresher is the bug the init
|
| 1295 |
+
// effect's note above describes. A LOCKED source's own workspace is a different question and
|
| 1296 |
+
// is unaffected: that is `hostWorkspace` true, `recordsMutable` false.
|
| 1297 |
+
if (!workspaceReady || !hostWorkspace) return;
|
| 1298 |
writeLocal(storageKey, {
|
| 1299 |
fields,
|
| 1300 |
views,
|
|
|
|
| 1311 |
// drop a view this browser created seconds ago.
|
| 1312 |
viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
|
| 1313 |
});
|
| 1314 |
+
}, [workspaceReady, storageKey, fields, views, activeViewId, hostWorkspace]);
|
| 1315 |
|
| 1316 |
/**
|
| 1317 |
* β THE LIVE WORKSPACE (owner report, 2026-08-04) β what has APPEARED since we mounted.
|
|
|
|
| 1336 |
*/
|
| 1337 |
const hostWorkspaceViews = payload?.workspace?.views;
|
| 1338 |
useEffect(() => {
|
| 1339 |
+
// `hostWorkspace`: this adopts views out of `payload.workspace`, which a Query surface never
|
| 1340 |
+
// fetches. Keyed on `embedded` it would read a workspace that is not there.
|
| 1341 |
+
if (!workspaceReady || !hostWorkspace) return;
|
| 1342 |
const now = Date.now();
|
| 1343 |
const nextFields = adoptNewFields(
|
| 1344 |
fields, payloadFields, fieldStampsRef.current.deleted, now
|
|
|
|
| 1348 |
adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
|
| 1349 |
(config) => normalizeConfig(config, nextFields))
|
| 1350 |
);
|
| 1351 |
+
}, [workspaceReady, hostWorkspaceViews, payloadFields, fields, hostWorkspace]);
|
| 1352 |
|
| 1353 |
/** Item 3c β stamp a def write / a delete. Pruned at every touch so the persisted blob
|
| 1354 |
* stays a recent window, never an archive. */
|
|
|
|
| 1413 |
* this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
|
| 1414 |
* row.
|
| 1415 |
*/
|
| 1416 |
+
const isUserTable = !embedded && scope.startsWith("ut_");
|
| 1417 |
const recordsMutable = payload?.recordsMutable !== false;
|
| 1418 |
const canMutateRecords = isUserTable && recordsMutable;
|
| 1419 |
/**
|
|
|
|
| 1571 |
|
| 1572 |
// Airtable behavior: configuration changes to the active view autosave.
|
| 1573 |
useEffect(() => {
|
| 1574 |
+
// ββ W35-T23 β a Query view AUTOSAVES like any other view; only its TRANSPORT differs.
|
| 1575 |
+
if (!workspaceReady || embedded) return;
|
| 1576 |
const active = views.find((view) => view.id === activeViewId);
|
| 1577 |
if (!active || sameConfig(active.config, config)) {
|
| 1578 |
setSaveState("saved");
|
|
|
|
| 1587 |
);
|
| 1588 |
viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
|
| 1589 |
Date.now());
|
| 1590 |
+
// β THE ROUTER, NEVER A BARE `emitHostEvent`. A raw host event carries the MODULE write
|
| 1591 |
+
// scope, which for a Query surface is the SOURCE database β so autosaving a resize would
|
| 1592 |
+
// have written a view into the source's own workspace under the artefact's id. The router
|
| 1593 |
+
// is what keeps "which store does this spec belong to" answered in one place.
|
| 1594 |
+
const routed = routeQueryViewMutation(queryBinding, scope, {
|
| 1595 |
+
id: eventId(queryBinding ? "query-view-update" : "view"),
|
| 1596 |
+
type: "view_upsert",
|
| 1597 |
+
view: updated as unknown as Record<string, unknown>,
|
| 1598 |
+
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
| 1599 |
+
if (routed.channel === "refused")
|
| 1600 |
+
signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
|
| 1601 |
+
// β REPORTED, not swallowed. A write that fails in silence reads exactly like a read that
|
| 1602 |
+
// never happened [[lost-write-looks-like-failed-read]], and this is the one path a person
|
| 1603 |
+
// triggers by dragging a column edge.
|
| 1604 |
+
else if (routed.channel === "query")
|
| 1605 |
+
void routed.result.then((result) => {
|
| 1606 |
+
if (!result.ok) signal(TOAST_EVENT, result.message);
|
| 1607 |
+
});
|
| 1608 |
setSaveState("saved");
|
| 1609 |
saveTimer.current = null;
|
| 1610 |
}, 420);
|
| 1611 |
return () => {
|
| 1612 |
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
|
| 1613 |
};
|
| 1614 |
+
}, [workspaceReady, activeViewId, config, views, embedded, queryBinding, scope]);
|
| 1615 |
|
| 1616 |
// Numeric dimensions force a glide relayout when either the component frame
|
| 1617 |
// or Streamlit's main column changes width (notably sidebar collapse).
|
|
|
|
| 1642 |
const mode = tableMode(payload?.counts);
|
| 1643 |
const serverWindowed = mode === "server-windowed";
|
| 1644 |
// Owner items 4+6 β the Cohort page's grid renders without the Views sidebar.
|
| 1645 |
+
const hideViews = !hostWorkspace || payload?.workspace?.hideViews === true;
|
| 1646 |
// Wave-6 item 10 β how this view displays. A WINDOWED table is always the grid: list/
|
| 1647 |
// calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
|
| 1648 |
// the same reason grouping is off there).
|
|
|
|
| 1780 |
// permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
|
| 1781 |
const viewer = payload?.viewer;
|
| 1782 |
const canEditField = useCallback(
|
| 1783 |
+
(f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer),
|
| 1784 |
+
[embedded, recordsMutable, viewer]
|
| 1785 |
);
|
| 1786 |
const unresolvedCount = useMemo(
|
| 1787 |
() => unresolvedConditions(config.filters, { cohortSets, today }),
|
|
|
|
| 1986 |
() => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
|
| 1987 |
[serverWindowed, payload?.counts?.matched]
|
| 1988 |
);
|
| 1989 |
+
/**
|
| 1990 |
+
* ββ W35-T24 (owner item 4 / R2, R6's second sentence) β WHY THERE IS NO TRAILING "+".
|
| 1991 |
+
*
|
| 1992 |
+
* β NOT SCOPED TO QUERY, DELIBERATELY, AND THAT IS WIDER THAN THE TICKET ASKED. The predicate
|
| 1993 |
+
* is the SOURCE's (`records_mutable`, DESIGN.md Β§4's locked-database vocabulary), so the answer
|
| 1994 |
+
* belongs to the database rather than to the surface looking at it. Scoping the sentence to
|
| 1995 |
+
* Query would make one database say two different things depending on which door you opened it
|
| 1996 |
+
* through, which is the failure a shared predicate exists to prevent.
|
| 1997 |
+
*
|
| 1998 |
+
* β `embedded` IS EXEMPT. A linked-record grid inside a modal has no "+" because it is a
|
| 1999 |
+
* picker, not a table, and a footnote apologising for that would be chrome in a dialog.
|
| 2000 |
+
*/
|
| 2001 |
+
/**
|
| 2002 |
+
* β W35-T30 (C5) β the list the RAIL renders: this workspace's views, plus the server's
|
| 2003 |
+
* "Starred records" projection when there is one. See the `views=` prop below for why it is
|
| 2004 |
+
* appended here and not merged into `views`.
|
| 2005 |
+
*/
|
| 2006 |
+
const railViews = useMemo(() => {
|
| 2007 |
+
const projection = recordStars.stars.view;
|
| 2008 |
+
if (!projection || views.some((view) => view.id === projection.id)) return views;
|
| 2009 |
+
return [...views, projection];
|
| 2010 |
+
}, [views, recordStars.stars.view]);
|
| 2011 |
+
const lockedNote = useMemo(
|
| 2012 |
+
// β The label is the ARTEFACT's source name when there is one, and otherwise nothing: the
|
| 2013 |
+
// payload carries no database name, and "This database" is unambiguous on a surface whose
|
| 2014 |
+
// header already says which one it is. Guessing a name from the scope key would print
|
| 2015 |
+
// `ut_odoo_customers` at somebody.
|
| 2016 |
+
() => (embedded ? null
|
| 2017 |
+
: lockedRecordsNote(recordsMutable, isUserTable, queryBinding?.source.label)),
|
| 2018 |
+
[embedded, recordsMutable, isUserTable, queryBinding]
|
| 2019 |
+
);
|
| 2020 |
const displayRows = useMemo(() => {
|
| 2021 |
const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
|
| 2022 |
// β APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β and it is the
|
|
|
|
| 2722 |
// and a `position: fixed` button with no clamp would paint over the chrome on a row
|
| 2723 |
// nobody can see. The horizontal legs are now defence in depth (a column drag, a box
|
| 2724 |
// narrower than the frozen strip) rather than the everyday case.
|
| 2725 |
+
const boxRect = gridBoxRef.current?.getBoundingClientRect();
|
| 2726 |
+
const at = b ? expandButtonRect(b, boxRect) : null;
|
| 2727 |
+
// W35-T29 β the star book-ends the same cell the Expand does, from the SAME bounds. It
|
| 2728 |
+
// has its own minima (smaller mark, left end), so a compact row that cannot carry the
|
| 2729 |
+
// Expand can still carry this one.
|
| 2730 |
+
const starAt = b ? starButtonRect(b, boxRect) : null;
|
| 2731 |
+
setStarHover((prev) =>
|
| 2732 |
+
!starAt
|
| 2733 |
+
? null
|
| 2734 |
+
: prev && prev.pid === pid && prev.x === starAt.x && prev.y === starAt.y
|
| 2735 |
+
? prev
|
| 2736 |
+
: { pid, x: starAt.x, y: starAt.y, size: starAt.size }
|
| 2737 |
+
);
|
| 2738 |
// Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over
|
| 2739 |
// the same record at a new y, and a button that keeps the pid but not the position
|
| 2740 |
// floats over the wrong row. Referential stability is preserved by comparing values,
|
|
|
|
| 3351 |
visibleCols, fieldByKey, canEditField]
|
| 3352 |
);
|
| 3353 |
|
| 3354 |
+
/**
|
| 3355 |
+
* ββ W35-T23 (R2) β ONE BODY FOR BOTH SURFACES, and the branch that used to sit here is why.
|
| 3356 |
+
*
|
| 3357 |
+
* It had a `if (queryBinding) { ...refuse; return; }` prologue that never touched `views`, so a
|
| 3358 |
+
* Query surface could not change its own spec at all. R2 makes it live, and the ONLY difference
|
| 3359 |
+
* left is which transport the router picks β which is `routeQueryViewMutation`'s whole job, so
|
| 3360 |
+
* asking the question twice (once here, once inside it) was the duplication that made the two
|
| 3361 |
+
* paths drift.
|
| 3362 |
+
*
|
| 3363 |
+
* β THE STATE UPDATE MUST HAPPEN FOR BOTH. Without it the autosave effect compares `config`
|
| 3364 |
+
* against a `views` entry that never moved, `sameConfig` stays false, and the effect re-fires
|
| 3365 |
+
* every render β a POST per frame for the life of the surface.
|
| 3366 |
+
*
|
| 3367 |
+
* β A view whose id is NOT the artefact's is still refused by the router (a Query workspace
|
| 3368 |
+
* holds exactly one view), so Duplicate cannot smuggle a second one in.
|
| 3369 |
+
*/
|
| 3370 |
const persistView = useCallback((view: SavedView) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3371 |
setViews((current) => {
|
| 3372 |
const found = current.some((item) => item.id === view.id);
|
| 3373 |
return found
|
| 3374 |
? current.map((item) => (item.id === view.id ? view : item))
|
| 3375 |
: [...current, view];
|
| 3376 |
});
|
| 3377 |
+
const routed = routeQueryViewMutation(queryBinding, scope, {
|
| 3378 |
+
id: eventId(queryBinding ? "query-view-update" : "view"),
|
| 3379 |
+
type: "view_upsert",
|
| 3380 |
+
view: view as unknown as Record<string, unknown>,
|
| 3381 |
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
| 3382 |
+
if (routed.channel === "refused")
|
| 3383 |
+
signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
|
| 3384 |
+
else if (routed.channel === "query")
|
| 3385 |
+
void routed.result.then((result) => {
|
| 3386 |
+
if (!result.ok) signal(TOAST_EVENT, result.message);
|
| 3387 |
+
});
|
| 3388 |
}, [queryBinding, scope]);
|
| 3389 |
|
| 3390 |
/**
|
|
|
|
| 3424 |
const current = views.find((view) => view.id === activeViewId);
|
| 3425 |
if (current && !sameConfig(current.config, config))
|
| 3426 |
persistView({ ...current, config });
|
| 3427 |
+
/**
|
| 3428 |
+
* ββ W35-T30 (C5) β "Starred records" IS SELECTABLE AND IS NOT IN `views`, DELIBERATELY.
|
| 3429 |
+
*
|
| 3430 |
+
* β IT IS THE SERVER'S PROJECTION, NOT A WORKSPACE VIEW. Half this component keys off
|
| 3431 |
+
* `views`: the autosave, the echo reconcile, `writeLocal`, `uniqueDisplayName`. Merging a
|
| 3432 |
+
* server-owned object into that state would put it into the autosave's comparison and this
|
| 3433 |
+
* browser would start writing a view it does not own β the shape D-170 refuses on a
|
| 3434 |
+
* read-through grid, arriving from the client side instead. So it joins the RAIL's list
|
| 3435 |
+
* (see `railViews`) and is resolved here by name, which is the same split the comment on
|
| 3436 |
+
* `applyViewOrder` makes for view ORDER: a rendering fact about one surface.
|
| 3437 |
+
*/
|
| 3438 |
+
const next = views.find((view) => view.id === id)
|
| 3439 |
+
?? (id === recordStars.stars.view?.id ? recordStars.stars.view : undefined);
|
| 3440 |
if (!next) return;
|
| 3441 |
setActiveViewId(id);
|
| 3442 |
setConfig(normalizeConfig(next.config, fields));
|
|
|
|
| 3451 |
// Owner item 10: opening a view is "I am working now" β the frame folds its nav rail.
|
| 3452 |
signal(NAV_MINIMIZE_EVENT);
|
| 3453 |
},
|
| 3454 |
+
[views, activeViewId, config, persistView, fields, isQueryPreview, recordStars.stars.view]
|
| 3455 |
);
|
| 3456 |
|
| 3457 |
/**
|
|
|
|
| 3855 |
* order β two lists that decide "is there a number here" by different rules would drift, and
|
| 3856 |
* the drift would show as a row wearing both a count and an excuse.
|
| 3857 |
*/
|
| 3858 |
+
/*
|
| 3859 |
+
* ββ `importantTotal` IS DELETED (W35-T27, owner item 9 / R4).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3860 |
*
|
| 3861 |
+
* W33-T16 built it to answer *"the database should have the sum number of all the views with
|
| 3862 |
+
* mark important numbers"*, and W34-T20 stripped its label. Owner item 9 removes the count
|
| 3863 |
+
* from every surface EXCEPT the view itself, so the rail badge it fed is gone and this memo
|
| 3864 |
+
* had no reader. Deleted rather than left computing a value nobody renders
|
| 3865 |
+
* [[artifact-with-no-importer]].
|
| 3866 |
*
|
| 3867 |
+
* β `importantIds` SURVIVES β it also feeds the `alerted βͺ important` union just above, which
|
| 3868 |
+
* is what decides which views get counted at all. This deletion is the SUM, not the set.
|
|
|
|
|
|
|
|
|
|
| 3869 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3870 |
|
| 3871 |
const countNotes = useMemo(() => {
|
| 3872 |
const out: Record<string, string> = {};
|
|
|
|
| 5431 |
const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
|
| 5432 |
const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
|
| 5433 |
// Item 10 β the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
|
| 5434 |
+
const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : (
|
| 5435 |
<ModeSwitch
|
| 5436 |
mode={displayMode}
|
| 5437 |
onMode={setDisplayMode}
|
|
|
|
| 5565 |
* fact about ONE surface; folding it into the state would make every consumer's
|
| 5566 |
* behaviour depend on a drag, and the drag's whole scope is which row sits where.
|
| 5567 |
*/
|
| 5568 |
+
/**
|
| 5569 |
+
* ββ W35-T30 Β· CONTRACT C5 (R5) β "Starred records" JOINS THE RAIL, NOT THE STATE.
|
| 5570 |
+
*
|
| 5571 |
+
* The server hands back the WHOLE view object (`kind: "system"`, `locked: true`,
|
| 5572 |
+
* `config.memberPids`), so this side never mints a second idea of what it is, and
|
| 5573 |
+
* `types.UNDELETABLE_VIEW_IDS` carries its id beside `all-customers` β the same two
|
| 5574 |
+
* facts that make that one undeletable make this one.
|
| 5575 |
+
*
|
| 5576 |
+
* β `view` IS `null` UNTIL SOMETHING IS STARRED, and the row is not drawn then. A
|
| 5577 |
+
* view promising starred records and listing none is the D-229 shape: a name that
|
| 5578 |
+
* asserts a set, over an empty one.
|
| 5579 |
+
* β AND IT IS APPENDED HERE rather than merged into `views` for the reason
|
| 5580 |
+
* `applyViewOrder` states one comment up: `views` is the workspace's list and the
|
| 5581 |
+
* autosave, the echo reconcile and `writeLocal` all key off it. A server-owned object
|
| 5582 |
+
* in that state means this browser starts writing a view it does not own.
|
| 5583 |
+
*/
|
| 5584 |
+
views={applyViewOrder(railViews, folderStamps, Date.now())}
|
| 5585 |
alertCounts={alertCounts}
|
| 5586 |
countNotes={countNotes}
|
|
|
|
| 5587 |
activeViewId={activeViewId}
|
| 5588 |
saveState={saveState}
|
| 5589 |
onSelect={selectView}
|
|
|
|
| 5849 |
// canvas, so reaching for it IS an out-of-bounds event and dismissing there would
|
| 5850 |
// unmount the control under the arriving pointer. Leaving the whole grid box is the
|
| 5851 |
// honest "you are done with this row".
|
| 5852 |
+
onMouseLeave={() => {
|
| 5853 |
+
setExpandAt((prev) => (prev === null ? prev : null));
|
| 5854 |
+
// W35-T29: the star control is dismissed with the Expand, and it is HOVER-SCOPED β
|
| 5855 |
+
// say so rather than implying otherwise. It is how you SET a star, and it shows
|
| 5856 |
+
// whether the row under the pointer has one. The persistent answer to "which records
|
| 5857 |
+
// are starred" is R5's own design: the undeletable "Starred records" view beside "All
|
| 5858 |
+
// records" (W35-T30). Marking every starred row in the canvas as well would need a
|
| 5859 |
+
// `getBounds` per visible row on every repaint, for a fact the view already answers.
|
| 5860 |
+
setStarHover((prev) => (prev === null ? prev : null));
|
| 5861 |
+
}}
|
| 5862 |
>
|
| 5863 |
{displayMode === "grid" && (
|
| 5864 |
<>
|
|
|
|
| 5942 |
height={gridSize.height}
|
| 5943 |
customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
|
| 5944 |
headerIcons={HEADER_ICONS}
|
| 5945 |
+
rightElement={embedded ? undefined : (
|
| 5946 |
// Wave-6 item 4 β the "+" of the header row: the create-field form,
|
| 5947 |
// insert-at-end.
|
| 5948 |
//
|
|
|
|
| 6136 |
docs={payload?.docs}
|
| 6137 |
docPayload={payload?.docPayload}
|
| 6138 |
onDocAdd={
|
| 6139 |
+
!embedded && payload?.docs
|
| 6140 |
? (pid, file) =>
|
| 6141 |
emitHostEvent({
|
| 6142 |
id: eventId("docadd"),
|
|
|
|
| 6148 |
: undefined
|
| 6149 |
}
|
| 6150 |
onDocFetch={
|
| 6151 |
+
!embedded && payload?.docs
|
| 6152 |
? (pid, docId) =>
|
| 6153 |
emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
|
| 6154 |
: undefined
|
| 6155 |
}
|
| 6156 |
onDocDelete={
|
| 6157 |
+
!embedded && payload?.docs
|
| 6158 |
? (pid, docId) =>
|
| 6159 |
emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
|
| 6160 |
: undefined
|
|
|
|
| 6322 |
</svg>
|
| 6323 |
</button>
|
| 6324 |
)}
|
| 6325 |
+
{/* ββ W35-T29 (R5, R6) β THE RECORD STAR, at the LEFT end of the row's primary cell,
|
| 6326 |
+
book-ending the Expand at its right end. A real DOM button over the canvas, at
|
| 6327 |
+
glide's own bounds, so the rect IS the hit test and there is no drawn affordance
|
| 6328 |
+
whose click target can drift from its paint ([[ui-invisible-to-assertions]]).
|
| 6329 |
+
|
| 6330 |
+
β HOVER-SCOPED, AND THAT IS STATED RATHER THAN IMPLIED. This is how you SET a star
|
| 6331 |
+
and it reports whether the row under the pointer has one. "Which records are
|
| 6332 |
+
starred" is answered by the undeletable "Starred records" view beside "All records"
|
| 6333 |
+
(W35-T30), which is R5's own design and costs no per-row bounds call.
|
| 6334 |
+
|
| 6335 |
+
β `recordStars.ready` gates it: before the read answers, this client does not know
|
| 6336 |
+
the current value, and a control drawn on a guess would flip the wrong way. */}
|
| 6337 |
+
{!embedded && displayMode === "grid" && starHover && recordStars.ready && (
|
| 6338 |
+
<button
|
| 6339 |
+
type="button"
|
| 6340 |
+
className={"cg-row-star" + (recordStars.starred(starHover.pid) ? " is-on" : "")}
|
| 6341 |
+
style={{
|
| 6342 |
+
left: starHover.x,
|
| 6343 |
+
top: starHover.y,
|
| 6344 |
+
width: starHover.size,
|
| 6345 |
+
height: starHover.size,
|
| 6346 |
+
}}
|
| 6347 |
+
aria-pressed={recordStars.starred(starHover.pid)}
|
| 6348 |
+
title={recordStars.starred(starHover.pid) ? "Unstar this record" : "Star this record"}
|
| 6349 |
+
aria-label={
|
| 6350 |
+
recordStars.starred(starHover.pid) ? "Unstar this record" : "Star this record"
|
| 6351 |
+
}
|
| 6352 |
+
// The same `preventDefault` the Expand needs: mousedown would otherwise reach the
|
| 6353 |
+
// canvas underneath, move the selection and repaint the row a frame before the
|
| 6354 |
+
// click lands, which reads as a flicker on every star.
|
| 6355 |
+
onMouseDown={(event) => event.preventDefault()}
|
| 6356 |
+
onClick={() =>
|
| 6357 |
+
recordStars.toggle(starHover.pid, !recordStars.starred(starHover.pid))}
|
| 6358 |
+
>
|
| 6359 |
+
<RecordStarMark size={13} filled={recordStars.starred(starHover.pid)} />
|
| 6360 |
+
</button>
|
| 6361 |
+
)}
|
| 6362 |
{/* Wave-5 item 6 β the header description tip. pointer-events:none + aria-hidden:
|
| 6363 |
it can never become a click target, so the control under it stays clickable
|
| 6364 |
(the tooltip-swallows-the-next-click trap, paid for once already). */}
|
|
|
|
| 6499 |
β The display cap and the server's limits are INDEPENDENT FACTS about one grid: one is
|
| 6500 |
how much we are painting, the other is what the server could not do. They belong in
|
| 6501 |
the same strip and neither may suppress the other. */}
|
| 6502 |
+
{(foldNote || limitNote || capabilityNote || lockedNote) && (
|
| 6503 |
<div className="cg-more">
|
| 6504 |
{foldNote && <span className="cg-more-note">{foldNote}</span>}
|
| 6505 |
{limitNote && (
|
|
|
|
| 6510 |
{capabilityNote.short}
|
| 6511 |
</span>
|
| 6512 |
)}
|
| 6513 |
+
{/* ββ W35-T24 β WHY THERE IS NO "+", in the strip that already answers "why is this
|
| 6514 |
+
grid not doing what I expected". β BOTH `title` AND `aria-label`, because D-253
|
| 6515 |
+
booked "explains why it cannot, on hover only" as a defect once already: a title
|
| 6516 |
+
is not announced and an aria-label does not hover. */}
|
| 6517 |
+
{lockedNote && (
|
| 6518 |
+
<span className="cg-more-note" title={lockedNote.full} aria-label={lockedNote.full}>
|
| 6519 |
+
{lockedNote.short}
|
| 6520 |
+
</span>
|
| 6521 |
+
)}
|
| 6522 |
</div>
|
| 6523 |
)}
|
| 6524 |
{capped && (displayMode === "grid" || displayMode === "list") && (
|
|
|
|
| 7286 |
fields={fields}
|
| 7287 |
record={detailRecord}
|
| 7288 |
titleKey={lockedKey}
|
| 7289 |
+
// β W35-T29 β the SAME star state the row control uses, never a second client. An
|
| 7290 |
+
// embedded grid has neither: `recordStars` is disabled there, so `ready` is false and
|
| 7291 |
+
// the drawer draws no control rather than a dead one.
|
| 7292 |
+
starred={recordStars.starred(detailPid)}
|
| 7293 |
+
{...(recordStars.ready
|
| 7294 |
+
? { onStar: (on: boolean) => recordStars.toggle(detailPid, on) }
|
| 7295 |
+
: {})}
|
| 7296 |
positionLabel={`Record ${dataPosition.toLocaleString()} of ${recordCount.toLocaleString()}`}
|
| 7297 |
canPrev={neighborExists(-1)}
|
| 7298 |
canNext={neighborExists(1)}
|
|
|
|
| 7313 |
// on the NEXT render via `payload.docPayload`; Documents.tsx matches it
|
| 7314 |
// to its own pending request before touching it.
|
| 7315 |
onDocAdd={
|
| 7316 |
+
!embedded && payload?.docs
|
| 7317 |
? (file) =>
|
| 7318 |
emitHostEvent({
|
| 7319 |
id: eventId("docadd"),
|
|
|
|
| 7325 |
: undefined
|
| 7326 |
}
|
| 7327 |
onDocFetch={
|
| 7328 |
+
!embedded && payload?.docs
|
| 7329 |
? (docId) =>
|
| 7330 |
emitHostEvent({
|
| 7331 |
id: eventId("docget"),
|
|
|
|
| 7336 |
: undefined
|
| 7337 |
}
|
| 7338 |
onDocDelete={
|
| 7339 |
+
!embedded && payload?.docs
|
| 7340 |
? (docId) =>
|
| 7341 |
emitHostEvent({
|
| 7342 |
id: eventId("docdel"),
|
web/src/customer-grid/RecordDetail.tsx
CHANGED
|
@@ -102,7 +102,13 @@ import { Documents } from "./Documents";
|
|
| 102 |
import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout";
|
| 103 |
import RecordComments from "./RecordComments";
|
| 104 |
import { isStreamlitComponent } from "./hostBridge";
|
|
|
|
|
|
|
|
|
|
| 105 |
import "./RecordDetail.css";
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
export interface RecordDetailProps {
|
| 108 |
fields: Field[];
|
|
@@ -116,6 +122,18 @@ export interface RecordDetailProps {
|
|
| 116 |
onClose: () => void;
|
| 117 |
onNotesChange: (key: string, value: string) => void;
|
| 118 |
onNotesCommit: (key: string, value: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
/** Wave-5 item 1 β the permissions courtesy check rides into the drawer too: a field the
|
| 120 |
* viewer may not edit renders read-only here, exactly as its cells do. */
|
| 121 |
viewer?: Viewer;
|
|
@@ -286,6 +304,8 @@ export default function RecordDetail({
|
|
| 286 |
onClose,
|
| 287 |
onNotesChange,
|
| 288 |
onNotesCommit,
|
|
|
|
|
|
|
| 289 |
viewer,
|
| 290 |
docs,
|
| 291 |
docPayload,
|
|
@@ -1133,6 +1153,25 @@ export default function RecordDetail({
|
|
| 1133 |
</button>
|
| 1134 |
<span className="cg-detail-pos">{positionLabel}</span>
|
| 1135 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1136 |
<button
|
| 1137 |
type="button"
|
| 1138 |
className="cg-detail-close"
|
|
|
|
| 102 |
import { FIELD_DRAG_TYPE, moveKey, nudgeKey, resolveFieldOrder } from "./recordLayout";
|
| 103 |
import RecordComments from "./RecordComments";
|
| 104 |
import { isStreamlitComponent } from "./hostBridge";
|
| 105 |
+
// β ALIASED for the same reason `CustomerGrid` aliases it: `./Stars`'s `StarIcon` is the RATING
|
| 106 |
+
// atom and this one is the record MARK. Two meanings, one noun, both reachable in this tree.
|
| 107 |
+
import { StarIcon as RecordStarMark } from "../ui/icons";
|
| 108 |
import "./RecordDetail.css";
|
| 109 |
+
// The drawer's star wears `.cg-detail-star`, which lives beside the grid's own row star β one
|
| 110 |
+
// sheet for one idea, imported by both modules that render its classes (W35-T22's lesson).
|
| 111 |
+
import "./rowStar.css";
|
| 112 |
|
| 113 |
export interface RecordDetailProps {
|
| 114 |
fields: Field[];
|
|
|
|
| 122 |
onClose: () => void;
|
| 123 |
onNotesChange: (key: string, value: string) => void;
|
| 124 |
onNotesCommit: (key: string, value: string) => void;
|
| 125 |
+
/**
|
| 126 |
+
* β W35-T29 (C5, R5/R6) β this record's star, and the two halves travel TOGETHER.
|
| 127 |
+
*
|
| 128 |
+
* β BOTH OPTIONAL AND BOTH FROM THE GRID'S OWN `useRecordStars`, never a second client here:
|
| 129 |
+
* one list, one answer. An embedded grid passes neither and the control is ABSENT rather than
|
| 130 |
+
* disabled β a dead star is worse than no star.
|
| 131 |
+
* β `starred` alone would render a control that cannot be changed, and `onStar` alone would
|
| 132 |
+
* render one that cannot say what it currently is; the JSX gates on `onStar` so the pair is
|
| 133 |
+
* effectively all-or-nothing.
|
| 134 |
+
*/
|
| 135 |
+
starred?: boolean;
|
| 136 |
+
onStar?: (on: boolean) => void;
|
| 137 |
/** Wave-5 item 1 β the permissions courtesy check rides into the drawer too: a field the
|
| 138 |
* viewer may not edit renders read-only here, exactly as its cells do. */
|
| 139 |
viewer?: Viewer;
|
|
|
|
| 304 |
onClose,
|
| 305 |
onNotesChange,
|
| 306 |
onNotesCommit,
|
| 307 |
+
starred = false,
|
| 308 |
+
onStar,
|
| 309 |
viewer,
|
| 310 |
docs,
|
| 311 |
docPayload,
|
|
|
|
| 1153 |
</button>
|
| 1154 |
<span className="cg-detail-pos">{positionLabel}</span>
|
| 1155 |
</div>
|
| 1156 |
+
{/* ββ W35-T29 (R5, R6) β THE SAME RECORD STAR AS THE GRID'S, in the drawer.
|
| 1157 |
+
β THE SAME STATE, NOT A SECOND ONE: `starred`/`onStar` come from the grid's own
|
| 1158 |
+
`useRecordStars`, so starring here and starring in the row are one act on one list.
|
| 1159 |
+
A drawer with its own client would answer the same question twice and disagree the
|
| 1160 |
+
moment either wrote [[one-evaluator-per-question]].
|
| 1161 |
+
β ABSENT, not disabled, when the caller passes no handler β an embedded grid has no
|
| 1162 |
+
record stars at all, and a dead control is worse than none. */}
|
| 1163 |
+
{onStar && (
|
| 1164 |
+
<button
|
| 1165 |
+
type="button"
|
| 1166 |
+
className={"cg-detail-star" + (starred ? " is-on" : "")}
|
| 1167 |
+
aria-pressed={starred}
|
| 1168 |
+
aria-label={starred ? "Unstar this record" : "Star this record"}
|
| 1169 |
+
title={starred ? "Unstar this record" : "Star this record"}
|
| 1170 |
+
onClick={() => onStar(!starred)}
|
| 1171 |
+
>
|
| 1172 |
+
<RecordStarMark size={16} filled={starred} />
|
| 1173 |
+
</button>
|
| 1174 |
+
)}
|
| 1175 |
<button
|
| 1176 |
type="button"
|
| 1177 |
className="cg-detail-close"
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -234,13 +234,10 @@ interface ViewSidebarProps {
|
|
| 234 |
/** β WAVE 33 Β· T14 (D-205) β for a counted view that got NO number, the sentence saying why.
|
| 235 |
* Never both: a row carries a count or a note, and `alertCounts` is checked first. */
|
| 236 |
countNotes?: Record<string, string>;
|
| 237 |
-
/*
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
* number, and the rail must say so rather than present a short total as a whole one.
|
| 242 |
-
*/
|
| 243 |
-
importantTotal?: { sum: number; counted: number; marked: number } | null;
|
| 244 |
/**
|
| 245 |
* C4 as AMENDED 2026-07-28 β the folder-level bulk "Add to cohort". The caller
|
| 246 |
* owns the arithmetic (it holds the engine); the rail owns the confirm.
|
|
@@ -307,7 +304,6 @@ export default function ViewSidebar({
|
|
| 307 |
onViewReorder,
|
| 308 |
alertCounts,
|
| 309 |
countNotes,
|
| 310 |
-
importantTotal,
|
| 311 |
folderAddPreview,
|
| 312 |
onFolderAddToList,
|
| 313 |
viewer,
|
|
@@ -623,47 +619,20 @@ export default function ViewSidebar({
|
|
| 623 |
const noteView = noteFor
|
| 624 |
? views.find((view) => view.id === noteFor.viewId)
|
| 625 |
: undefined;
|
| 626 |
-
/*
|
| 627 |
-
*
|
| 628 |
-
*
|
| 629 |
-
* R1 drops the word "Important" from this badge, so what is left on screen is a figure with
|
| 630 |
-
* no noun. W33-T16's own note argued the opposite ("an unlabelled figure at the top of a list
|
| 631 |
-
* of views reads as a count OF VIEWS") and it was right about the risk; the owner's ruling
|
| 632 |
-
* settles the pixels, and this is where the meaning goes instead. Hover and screen reader get
|
| 633 |
-
* the whole fact, in one string, on the one element.
|
| 634 |
*
|
| 635 |
-
*
|
| 636 |
-
*
|
| 637 |
-
*
|
| 638 |
-
*
|
| 639 |
-
*
|
| 640 |
-
* (T14), and never a manufactured 0.
|
| 641 |
*
|
| 642 |
-
* β
|
| 643 |
-
*
|
| 644 |
-
*
|
| 645 |
-
*
|
| 646 |
*/
|
| 647 |
-
let importantNote = "";
|
| 648 |
-
if (importantTotal) {
|
| 649 |
-
const { sum, counted, marked } = importantTotal;
|
| 650 |
-
const nViews = `${marked} view${marked === 1 ? "" : "s"} marked important in this database`;
|
| 651 |
-
if (counted === 0) {
|
| 652 |
-
const cause = views
|
| 653 |
-
.filter((v) => v.config?.important)
|
| 654 |
-
.map((v) => countNotes?.[v.id])
|
| 655 |
-
.find(Boolean);
|
| 656 |
-
importantNote =
|
| 657 |
-
`No number yet for the ${nViews}. `
|
| 658 |
-
+ (cause || "Open a marked view to see why its own count is missing.");
|
| 659 |
-
} else if (counted < marked) {
|
| 660 |
-
importantNote =
|
| 661 |
-
`${sum.toLocaleString()} records across ${counted} of the ${nViews}. `
|
| 662 |
-
+ `The rest cannot be counted in the browser, so this total is lower than the real one.`;
|
| 663 |
-
} else {
|
| 664 |
-
importantNote = `${sum.toLocaleString()} records across the ${nViews}.`;
|
| 665 |
-
}
|
| 666 |
-
}
|
| 667 |
|
| 668 |
const create = () => {
|
| 669 |
const value = name.trim();
|
|
@@ -796,64 +765,19 @@ export default function ViewSidebar({
|
|
| 796 |
/>
|
| 797 |
</svg>
|
| 798 |
</button>
|
| 799 |
-
{/*
|
| 800 |
-
Owner:
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
the
|
| 804 |
-
β
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
database NAVIGATION (B's flyout, contract C1), and what stays here is the number
|
| 813 |
-
alone, smaller, with no word. The risk T16 named is real and is answered by
|
| 814 |
-
`importantNote` rather than by a label: the badge carries the whole sentence in its
|
| 815 |
-
`title` AND its `aria-label`, so hover and screen reader both get the noun the pixels
|
| 816 |
-
gave up. β Do not re-add the word to satisfy the old note; `verify_grid_ux`'s
|
| 817 |
-
`[VIEWS RAIL]` scan reds on it, with a control that reproduces the regression. */}
|
| 818 |
-
{importantTotal && (
|
| 819 |
-
<span
|
| 820 |
-
className={
|
| 821 |
-
"cg-views-important" +
|
| 822 |
-
// β PARTIAL means "the number you can see is real and short". A total that counted
|
| 823 |
-
// NOTHING has no number to be short, so it must not wear the dashed-purple partial
|
| 824 |
-
// mark as well as the is-unknown one β two statements about one absence.
|
| 825 |
-
(importantTotal.counted > 0 && importantTotal.counted < importantTotal.marked
|
| 826 |
-
? " is-partial"
|
| 827 |
-
: "")
|
| 828 |
-
}
|
| 829 |
-
>
|
| 830 |
-
{/* β `role="img"` ON BOTH, and it is not decoration. An `aria-label` on a plain
|
| 831 |
-
roleless inline `<span>` is not reliably exposed by assistive technology, and
|
| 832 |
-
this badge is now the ONLY carrier of a noun the pixels no longer say. `img` is
|
| 833 |
-
the correct role for "a graphic whose whole meaning is its label", and it also
|
| 834 |
-
stops a screen reader announcing the bare digits as loose text. */}
|
| 835 |
-
{importantTotal.counted === 0 ? (
|
| 836 |
-
<span
|
| 837 |
-
className="cg-view-count is-unknown"
|
| 838 |
-
role="img"
|
| 839 |
-
title={importantNote}
|
| 840 |
-
aria-label={importantNote}
|
| 841 |
-
>
|
| 842 |
-
-
|
| 843 |
-
</span>
|
| 844 |
-
) : (
|
| 845 |
-
<span
|
| 846 |
-
className="cg-view-count"
|
| 847 |
-
role="img"
|
| 848 |
-
title={importantNote}
|
| 849 |
-
aria-label={importantNote}
|
| 850 |
-
>
|
| 851 |
-
{importantTotal.sum.toLocaleString()}
|
| 852 |
-
{importantTotal.counted < importantTotal.marked ? "+" : ""}
|
| 853 |
-
</span>
|
| 854 |
-
)}
|
| 855 |
-
</span>
|
| 856 |
-
)}
|
| 857 |
</div>
|
| 858 |
{/*
|
| 859 |
I13 β the "Views / All changes saved" header block is GONE and everything moved up.
|
|
@@ -1499,10 +1423,13 @@ export default function ViewSidebar({
|
|
| 1499 |
// same place), so a single sentence about alerting would tell a reader who
|
| 1500 |
// marked a view that they had created an alert they did not create. The
|
| 1501 |
// number is the same fact; the promise attached to it is not.
|
|
|
|
|
|
|
|
|
|
| 1502 |
title={
|
| 1503 |
view.config?.important
|
| 1504 |
? `${alertCounts[view.id].toLocaleString()} records match this view. `
|
| 1505 |
-
+ `You
|
| 1506 |
: `${alertCounts[view.id].toLocaleString()} records match this `
|
| 1507 |
+ `view. You are alerted when a new one arrives.`
|
| 1508 |
}
|
|
@@ -2069,16 +1996,25 @@ export default function ViewSidebar({
|
|
| 2069 |
holds views; the scope key belongs to the route) β and because the server
|
| 2070 |
refuses a view with no active filter, which is a message the frame is
|
| 2071 |
already in the business of showing. */}
|
| 2072 |
-
{/* ββ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β
|
| 2073 |
β ABOVE the alert row, which is the done-when's own word and not a nicety: the two
|
| 2074 |
rows produce THE SAME red pill in the rail, and the cheap, private, instantly
|
| 2075 |
reversible one has to be reachable before the one that creates a stored server-side
|
| 2076 |
alert. Offered first, a user who only wanted to keep an eye on a number never has
|
| 2077 |
to make an alert to get one.
|
| 2078 |
-
β ONE MARK. R5 is explicit: no second severity, no colour variants β
|
| 2079 |
-
`verify_icons` asserts the absence of
|
| 2080 |
copy-paste from this very row. The label FLIPS rather than the state being implied,
|
| 2081 |
-
because "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2082 |
{onToggleImportant && (
|
| 2083 |
<button
|
| 2084 |
type="button"
|
|
@@ -2099,11 +2035,11 @@ export default function ViewSidebar({
|
|
| 2099 |
`MenuLabel` takes `MenuIconName | ReactNode`, so a glyph no other menu row needs
|
| 2100 |
costs the shared `MENU_ICONS` vocabulary nothing.
|
| 2101 |
β `filled` is what says the view IS marked, so the pair still reads mark/unmark.
|
| 2102 |
-
β `verify_icons`'s T24 leg pins
|
| 2103 |
-
|
| 2104 |
<MenuLabel
|
| 2105 |
icon={<StarIcon size={16} filled={!!menuView.config?.important} />}
|
| 2106 |
-
text={menuView.config?.important ? "
|
| 2107 |
/>
|
| 2108 |
</button>
|
| 2109 |
)}
|
|
|
|
| 234 |
/** β WAVE 33 Β· T14 (D-205) β for a counted view that got NO number, the sentence saying why.
|
| 235 |
* Never both: a row carries a count or a note, and `alertCounts` is checked first. */
|
| 236 |
countNotes?: Record<string, string>;
|
| 237 |
+
/* β `importantTotal` IS GONE (W35-T27, owner item 9). It carried W33-T16's summed badge at the
|
| 238 |
+
top of this rail; the owner removed the count from everywhere except the view itself, so the
|
| 239 |
+
prop has no reader and its memo in `CustomerGrid` went with it rather than being left to
|
| 240 |
+
compute a value nobody renders [[artifact-with-no-importer]]. */
|
|
|
|
|
|
|
|
|
|
| 241 |
/**
|
| 242 |
* C4 as AMENDED 2026-07-28 β the folder-level bulk "Add to cohort". The caller
|
| 243 |
* owns the arithmetic (it holds the engine); the rail owns the confirm.
|
|
|
|
| 304 |
onViewReorder,
|
| 305 |
alertCounts,
|
| 306 |
countNotes,
|
|
|
|
| 307 |
folderAddPreview,
|
| 308 |
onFolderAddToList,
|
| 309 |
viewer,
|
|
|
|
| 619 |
const noteView = noteFor
|
| 620 |
? views.find((view) => view.id === noteFor.viewId)
|
| 621 |
: undefined;
|
| 622 |
+
/*
|
| 623 |
+
* ββ `importantNote` IS DELETED WITH THE BADGE IT LABELLED (W35-T27, owner item 9 / R4).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
*
|
| 625 |
+
* It existed because W34-T20's ruling took the WORD off the rail's summed badge and left a
|
| 626 |
+
* figure with no noun, so the whole sentence moved into that one element's `title` and
|
| 627 |
+
* `aria-label`. Item 9 removes the badge itself, so there is no element left to label. The
|
| 628 |
+
* per-view badges keep their own `countNotes` sentence (see the view row below) β that
|
| 629 |
+
* derivation is separate and is untouched.
|
|
|
|
| 630 |
*
|
| 631 |
+
* β D-252's lesson does NOT die here, it moves: "a total that could count nothing must not
|
| 632 |
+
* paint a manufactured 0" now lives entirely in the per-view badge and in E's
|
| 633 |
+
* `GET /starred/counts`, whose `notes` map carries the cause for a view whose count is not
|
| 634 |
+
* free (contract C3). Nothing about it is now stated in two places.
|
| 635 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
const create = () => {
|
| 638 |
const value = name.trim();
|
|
|
|
| 765 |
/>
|
| 766 |
</svg>
|
| 767 |
</button>
|
| 768 |
+
{/* ββ THE DATABASE'S OWN TOTAL IS DELETED FROM THIS ROW (W35-T27, owner item 9 / R4).
|
| 769 |
+
Owner, verbatim: *"remove [the important count] anywhere else but the View itself."*
|
| 770 |
+
W33-T16 put a summed badge here and W34-T20 stripped its label; item 9 removes the
|
| 771 |
+
badge outright. **The PER-VIEW badge on each view row is untouched** β that is what
|
| 772 |
+
"the View itself" means, and it is the half that survives.
|
| 773 |
+
β THE ROW IS NOT EMPTY: `.cg-views-top` still carries the minimize toggle, which is
|
| 774 |
+
now its ONLY child and therefore genuinely first (W35-T28). A's W35-T11 flipped this
|
| 775 |
+
row to `justify-content: flex-start` and deleted all four `.cg-views-important*`
|
| 776 |
+
rules; the badge's `margin-left: auto` had been OUTRANKING that flex rule, which is
|
| 777 |
+
why the toggle sat left on a database with a marked view and right on one without.
|
| 778 |
+
β `verify_grid_ux.py`'s `[VIEWS RAIL]` scan is INVERTED, not deleted: it asserts the
|
| 779 |
+
badge's ABSENCE from this row and its PRESENCE on the view rows, so re-adding one
|
| 780 |
+
reds it. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
</div>
|
| 782 |
{/*
|
| 783 |
I13 β the "Views / All changes saved" header block is GONE and everything moved up.
|
|
|
|
| 1423 |
// same place), so a single sentence about alerting would tell a reader who
|
| 1424 |
// marked a view that they had created an alert they did not create. The
|
| 1425 |
// number is the same fact; the promise attached to it is not.
|
| 1426 |
+
// β W35-T26 (R4): the word on screen is STAR. The stored key is still
|
| 1427 |
+
// `config.important` β R4 makes them ONE flag, so renaming the key would
|
| 1428 |
+
// create the second store the ruling forbids.
|
| 1429 |
title={
|
| 1430 |
view.config?.important
|
| 1431 |
? `${alertCounts[view.id].toLocaleString()} records match this view. `
|
| 1432 |
+
+ `You starred it.`
|
| 1433 |
: `${alertCounts[view.id].toLocaleString()} records match this `
|
| 1434 |
+ `view. You are alerted when a new one arrives.`
|
| 1435 |
}
|
|
|
|
| 1996 |
holds views; the scope key belongs to the route) β and because the server
|
| 1997 |
refuses a view with no active filter, which is a message the frame is
|
| 1998 |
already in the business of showing. */}
|
| 1999 |
+
{/* ββ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β the one mark.
|
| 2000 |
β ABOVE the alert row, which is the done-when's own word and not a nicety: the two
|
| 2001 |
rows produce THE SAME red pill in the rail, and the cheap, private, instantly
|
| 2002 |
reversible one has to be reachable before the one that creates a stored server-side
|
| 2003 |
alert. Offered first, a user who only wanted to keep an eye on a number never has
|
| 2004 |
to make an alert to get one.
|
| 2005 |
+
β ONE MARK. W32-R5 is explicit: no second severity, no colour variants β
|
| 2006 |
+
`verify_icons` asserts the absence of a second one so it cannot arrive by
|
| 2007 |
copy-paste from this very row. The label FLIPS rather than the state being implied,
|
| 2008 |
+
because a "star this" label on an already-starred view reads as a label for the view.
|
| 2009 |
+
|
| 2010 |
+
ββ W35-T26 (owner item 9 / R4) β THE LABEL IS "STAR" NOW, AND ONLY THE LABEL.
|
| 2011 |
+
Owner: star and mark-important are ONE idea and ONE flag, and the word on screen is
|
| 2012 |
+
**Star**. So this pair reads Star / Unstar, the glyph was ALREADY a star (W33-T15),
|
| 2013 |
+
and the STORED key stays `config.important` β renaming the key would create exactly
|
| 2014 |
+
the second store R4 forbids, and would 400 every view already marked.
|
| 2015 |
+
β `verify_icons.py`'s T24 leg PINS these two strings and is A's file, so it reds on
|
| 2016 |
+
this rename until A retargets it at the CLAIM (one mark, above the alert row) rather
|
| 2017 |
+
than the spelling [[a-gate-that-pins-a-spelling-not-a-claim]]. Raised as `ASK C-10`. */}
|
| 2018 |
{onToggleImportant && (
|
| 2019 |
<button
|
| 2020 |
type="button"
|
|
|
|
| 2035 |
`MenuLabel` takes `MenuIconName | ReactNode`, so a glyph no other menu row needs
|
| 2036 |
costs the shared `MENU_ICONS` vocabulary nothing.
|
| 2037 |
β `filled` is what says the view IS marked, so the pair still reads mark/unmark.
|
| 2038 |
+
β `verify_icons`'s T24 leg pins this row's position above the alert row, which
|
| 2039 |
+
does not move here; W35-T26 changes only the two LABEL strings. */}
|
| 2040 |
<MenuLabel
|
| 2041 |
icon={<StarIcon size={16} filled={!!menuView.config?.important} />}
|
| 2042 |
+
text={menuView.config?.important ? "Unstar" : "Star"}
|
| 2043 |
/>
|
| 2044 |
</button>
|
| 2045 |
)}
|
web/src/customer-grid/counts.ts
CHANGED
|
@@ -317,6 +317,55 @@ export function windowedCapabilityNote(matched: number): { short: string; full:
|
|
| 317 |
};
|
| 318 |
}
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
/**
|
| 321 |
* The predicate this browser has asked the SERVER to evaluate, as one comparable string.
|
| 322 |
*
|
|
|
|
| 317 |
};
|
| 318 |
}
|
| 319 |
|
| 320 |
+
/**
|
| 321 |
+
* ββ W35-T24 (owner item 4 / R2, and R6's SECOND SENTENCE) β WHY THIS GRID HAS NO "+".
|
| 322 |
+
*
|
| 323 |
+
* β THE DEFECT THIS EXISTS FOR IS AN ABSENCE, WHICH IS WHY NOTHING CAUGHT IT. A grid whose source
|
| 324 |
+
* cannot take a record simply has no trailing row: correct, and indistinguishable from a table
|
| 325 |
+
* that has not loaded, from a permission the reader lacks, and from a bug. D-258 is the same
|
| 326 |
+
* shape one column over (ten blank columns on a locked connected-source grid with nothing in the
|
| 327 |
+
* payload saying why) and it was booked as a defect, not as restraint. R6's rule is that a limit
|
| 328 |
+
* which cannot be removed must be REPORTED with its cause and a recommended next step.
|
| 329 |
+
*
|
| 330 |
+
* β TWO DIFFERENT CAUSES, TWO DIFFERENT SENTENCES, because only one of them has a next step:
|
| 331 |
+
* Β· a LOCKED database (`recordsMutable === false`) β rows arrive from a connector and are
|
| 332 |
+
* replaced by it, so adding one here would be overwritten. The next step is real: add it in
|
| 333 |
+
* the system that owns the records, and it appears here on the next sync.
|
| 334 |
+
* Β· a BUILT-IN database (not `ut_*`) β there is no rows endpoint to POST to at all. The next
|
| 335 |
+
* step is a different door, not this one.
|
| 336 |
+
* Collapsing them into "you cannot add records here" would tell the first reader nothing they can
|
| 337 |
+
* act on, which is the half of R6 that gets dropped.
|
| 338 |
+
*
|
| 339 |
+
* β THE SHORT FORM IS A SENTENCE, NOT A LABEL. D-253 booked "explains why it cannot, on hover
|
| 340 |
+
* only" as a defect once already; the caller puts `full` in BOTH `title` and `aria-label`, and
|
| 341 |
+
* `short` has to stand alone for a reader who never hovers.
|
| 342 |
+
*/
|
| 343 |
+
export function lockedRecordsNote(
|
| 344 |
+
recordsMutable: boolean,
|
| 345 |
+
isUserTable: boolean,
|
| 346 |
+
label?: string,
|
| 347 |
+
): { short: string; full: string } | null {
|
| 348 |
+
if (recordsMutable && isUserTable) return null;
|
| 349 |
+
const name = (label || "").trim() || "This database";
|
| 350 |
+
if (!recordsMutable) {
|
| 351 |
+
return {
|
| 352 |
+
short: "Records here come from a connected source",
|
| 353 |
+
full:
|
| 354 |
+
`${name} is kept in step with a connected system, so records cannot be added, deleted `
|
| 355 |
+
+ `or removed from this grid: the next sync would replace them. Columns you add here are `
|
| 356 |
+
+ `yours and are kept. To add a record, add it in the system the data comes from and it `
|
| 357 |
+
+ `arrives here on the next sync.`,
|
| 358 |
+
};
|
| 359 |
+
}
|
| 360 |
+
return {
|
| 361 |
+
short: "This database does not take new records here",
|
| 362 |
+
full:
|
| 363 |
+
`${name} is built from data this product assembles rather than from rows people type, so `
|
| 364 |
+
+ `there is no place for this grid to put a new record. Everything else works: filter, `
|
| 365 |
+
+ `sort, group, add columns and edit the cells you own.`,
|
| 366 |
+
};
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
/**
|
| 370 |
* The predicate this browser has asked the SERVER to evaluate, as one comparable string.
|
| 371 |
*
|
web/src/customer-grid/overlayPlacement.ts
CHANGED
|
@@ -423,6 +423,52 @@ export function expandButtonRect(
|
|
| 423 |
return { x, y, size };
|
| 424 |
}
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
/**
|
| 427 |
* Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
|
| 428 |
*
|
|
|
|
| 423 |
return { x, y, size };
|
| 424 |
}
|
| 425 |
|
| 426 |
+
// ------------------------------------------------ W35-T29 (R5): the record star, on the row
|
| 427 |
+
|
| 428 |
+
/** Smaller than the Expand button: a mark, not an action with a destination. */
|
| 429 |
+
export const STAR_BTN_SIZE = 18;
|
| 430 |
+
/** From the LEFT edge of the primary cell, mirroring `EXPAND_BTN_INSET` at the other end. */
|
| 431 |
+
export const STAR_BTN_INSET = 4;
|
| 432 |
+
|
| 433 |
+
/**
|
| 434 |
+
* ββ W35-T29 (R5) β where the row's star sits: **at the LEFT end of the primary cell**, which is
|
| 435 |
+
* the gutter position in this product. Airtable puts the row's own marks there; our Expand button
|
| 436 |
+
* already occupies the RIGHT end of the same cell (`expandButtonRect`), so the two book-end the
|
| 437 |
+
* primary cell and neither can land on the other.
|
| 438 |
+
*
|
| 439 |
+
* β THE SAME BOUNDS, FROM THE SAME SOURCE. `primary` is glide's own `getBounds(0, row)`, so
|
| 440 |
+
* freeze, horizontal scroll and row-height mode are handled by the component that owns them
|
| 441 |
+
* rather than re-derived here β and because the button is a real `<button>` over the canvas, this
|
| 442 |
+
* rect IS the hit test. A canvas-DRAWN star would need a second copy of this arithmetic for its
|
| 443 |
+
* click target, which is the shape [[ui-invisible-to-assertions]] names.
|
| 444 |
+
*
|
| 445 |
+
* β ITS MINIMA ARE ITS OWN AND ARE LOWER THAN THE EXPAND'S. This mark is smaller and sits where
|
| 446 |
+
* the text starts rather than where it ends, so a row that cannot carry the Expand can still
|
| 447 |
+
* carry this. Sharing `EXPAND_BTN_MIN_ROW` would have hidden the star on exactly the compact row
|
| 448 |
+
* heights a long list is read at.
|
| 449 |
+
*
|
| 450 |
+
* `null` when the row is too short, or when the rect would fall outside the grid's own box β
|
| 451 |
+
* absent rather than clamped, for the same reason the Expand is: a clamped `position: fixed`
|
| 452 |
+
* button sits at the edge pointing at a row whose cell is somewhere else.
|
| 453 |
+
*/
|
| 454 |
+
export function starButtonRect(
|
| 455 |
+
primary: { x: number; y: number; width: number; height: number },
|
| 456 |
+
box?: { x: number; y: number; width: number; height: number },
|
| 457 |
+
size = STAR_BTN_SIZE,
|
| 458 |
+
inset = STAR_BTN_INSET
|
| 459 |
+
): { x: number; y: number; size: number } | null {
|
| 460 |
+
if (primary.height < size + 2) return null;
|
| 461 |
+
// It must leave room for a readable label beside it, or the mark covers the name it marks.
|
| 462 |
+
if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null;
|
| 463 |
+
const x = Math.round(primary.x + inset);
|
| 464 |
+
const y = Math.round(primary.y + (primary.height - size) / 2);
|
| 465 |
+
if (box) {
|
| 466 |
+
if (x < box.x || x + size > box.x + box.width) return null;
|
| 467 |
+
if (y < box.y || y + size > box.y + box.height) return null;
|
| 468 |
+
}
|
| 469 |
+
return { x, y, size };
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
/**
|
| 473 |
* Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
|
| 474 |
*
|
web/src/customer-grid/queryPreview.ts
CHANGED
|
@@ -48,9 +48,23 @@ export type QueryViewMutationRoute<T> =
|
|
| 48 |
| { channel: "native" };
|
| 49 |
|
| 50 |
/**
|
| 51 |
-
* The sole bridge between existing view callbacks and a virtual Query artefact. Query owns
|
| 52 |
-
*
|
| 53 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
*/
|
| 55 |
export function routeQueryViewMutation<T>(
|
| 56 |
binding: QueryVirtualBinding | undefined,
|
|
@@ -65,6 +79,12 @@ export function routeQueryViewMutation<T>(
|
|
| 65 |
if (!acceptsQueryPreview(binding, scope)) return { channel: "refused", refusal: null };
|
| 66 |
if (event.type === "view_delete" && event.viewId === binding.artifactId)
|
| 67 |
return { channel: "query", result: sinks.query(binding, event) };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return {
|
| 69 |
channel: "refused",
|
| 70 |
refusal: refuseQueryMutation(binding, event.type === "view_create" ? "create" : "update"),
|
|
|
|
| 48 |
| { channel: "native" };
|
| 49 |
|
| 50 |
/**
|
| 51 |
+
* The sole bridge between existing view callbacks and a virtual Query artefact. Query owns the
|
| 52 |
+
* routing policy: every mutation of the artefact's OWN view travels on E's Query transport and
|
| 53 |
+
* nothing else may. A denied/mismatched binding cannot fall through to `native`.
|
| 54 |
+
*
|
| 55 |
+
* ββ W35-T23 (owner item 4 / R2) β **`view_upsert` ON THE ARTEFACT ITSELF NOW ROUTES INSTEAD OF
|
| 56 |
+
* BEING REFUSED.** Owner: *"I should be able to interact in each of the View under Query as well,
|
| 57 |
+
* exactly like how I would be able to interact with it under Database view."* Resizing a column,
|
| 58 |
+
* sorting, grouping, hiding and row height are all one thing to this function β a view spec being
|
| 59 |
+
* written β so making them work is a routing change, not a permission change.
|
| 60 |
+
*
|
| 61 |
+
* β TWO REFUSALS SURVIVE ON PURPOSE, AND THEY ARE THE ONES THAT ARE NOT ABOUT READ-ONLY:
|
| 62 |
+
* Β· **`view_create` stays refused.** A Query workspace holds EXACTLY ONE view, the artefact. A
|
| 63 |
+
* second one would have no artefact behind it, no citations, and no id the server could route.
|
| 64 |
+
* Β· **an upsert naming a DIFFERENT id stays refused.** Duplicate-view mints a new id and calls
|
| 65 |
+
* the same door; without this clause it would create that second view by another name.
|
| 66 |
+
* The delete rule is unchanged, and all three now read the same way: the artefact's own id is the
|
| 67 |
+
* only thing this transport will carry.
|
| 68 |
*/
|
| 69 |
export function routeQueryViewMutation<T>(
|
| 70 |
binding: QueryVirtualBinding | undefined,
|
|
|
|
| 79 |
if (!acceptsQueryPreview(binding, scope)) return { channel: "refused", refusal: null };
|
| 80 |
if (event.type === "view_delete" && event.viewId === binding.artifactId)
|
| 81 |
return { channel: "query", result: sinks.query(binding, event) };
|
| 82 |
+
// β The id is read off the VIEW here, not off `event.viewId` β an upsert carries its subject
|
| 83 |
+
// in the payload, and a caller that set neither must not be treated as naming the artefact.
|
| 84 |
+
if (event.type === "view_upsert"
|
| 85 |
+
&& typeof (event.view as { id?: unknown } | undefined)?.id === "string"
|
| 86 |
+
&& (event.view as { id: string }).id === binding.artifactId)
|
| 87 |
+
return { channel: "query", result: sinks.query(binding, event) };
|
| 88 |
return {
|
| 89 |
channel: "refused",
|
| 90 |
refusal: refuseQueryMutation(binding, event.type === "view_create" ? "create" : "update"),
|
web/src/customer-grid/recordStars.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / recordStars.ts β W35-T29 + W35-T30, CONTRACT C5 (rulings R5, R6).
|
| 3 |
+
//
|
| 4 |
+
// ββ THE RECORD STAR IS A DIFFERENT SCOPE FROM EVERY OTHER STAR IN THIS PRODUCT, and that is
|
| 5 |
+
// the one thing to carry away from this file. A star on a DATABASE, an AGENT or a QUERY is
|
| 6 |
+
// `config.important` on one record, so starring a SHARED view stars it for everybody who can see
|
| 7 |
+
// it (mailbox E-3). A RECORD star is PER USER (R6): it lives in the same per-username stratum
|
| 8 |
+
// views already live in, and two accounts looking at one row see two different answers. One word
|
| 9 |
+
// on screen, two scopes underneath.
|
| 10 |
+
//
|
| 11 |
+
// β WHY THIS IS ITS OWN DOOR AND NOT A VIEW WRITE (D-170). A view write on a read-through grid
|
| 12 |
+
// answers 409 `window_required`: `/grid/events` β `ut_write_ctx` β `scoped_pids` materialises the
|
| 13 |
+
// whole pool, and on `ut_odoo_gl_lines` that is 971,034 rows. E's route writes a `starredRecords`
|
| 14 |
+
// key in the per-user stratum and asks for no pid set, so it answers 200 on the big grids β proven
|
| 15 |
+
// on a seeded read-through table, with the store showing `['starredRecords']` and NO `views` key.
|
| 16 |
+
//
|
| 17 |
+
// β AND IT DELIBERATELY DOES NOT VALIDATE THE ID against the row set, for the same reason:
|
| 18 |
+
// checking that a pid exists means materialising the pool, i.e. the 409 again. The READ prunes β
|
| 19 |
+
// `memberPids` is intersected with the rows the caller can see, exactly as a cohort already is.
|
| 20 |
+
// ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 23 |
+
|
| 24 |
+
import { API_V1, CREDENTIALS, TOAST_EVENT, signal } from "../apiContract";
|
| 25 |
+
import type { SavedView } from "./types";
|
| 26 |
+
import { topicForScope } from "./types";
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* β E's CONSTANTS, IMPORTED IN SPIRIT AND RESTATED HERE BECAUSE THE WIRE IS THE CONTRACT.
|
| 30 |
+
* `routes_starred.STARRED_VIEW_ID` is `"starred-records"` and `STARRED_VIEW_NAME` is
|
| 31 |
+
* `"Starred records"`. This client never MINTS the view β the server hands the whole object back
|
| 32 |
+
* β so these exist only to recognise it and to keep it undeletable (`types.UNDELETABLE_VIEW_IDS`).
|
| 33 |
+
*/
|
| 34 |
+
export const STARRED_VIEW_ID = "starred-records";
|
| 35 |
+
|
| 36 |
+
export interface RecordStars {
|
| 37 |
+
/** The database key the answer is about, echoed so a late response cannot be misfiled. */
|
| 38 |
+
database: string;
|
| 39 |
+
count: number;
|
| 40 |
+
ids: number[];
|
| 41 |
+
/**
|
| 42 |
+
* β THE WHOLE VIEW OBJECT, NOT AN ID LIST, and E published it that way on purpose: the rail
|
| 43 |
+
* would otherwise have to mint a second idea of what this view is, and the two would drift.
|
| 44 |
+
* β `null` WHEN NOTHING IS STARRED. A view promising starred records and listing none is the
|
| 45 |
+
* D-229 shape, so the row is not drawn at all until there is something in it.
|
| 46 |
+
*/
|
| 47 |
+
view: SavedView | null;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export const NO_RECORD_STARS: RecordStars =
|
| 51 |
+
{ database: "", count: 0, ids: [], view: null };
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* The database key C5's door takes, from the grid's SCOPE.
|
| 55 |
+
*
|
| 56 |
+
* β THESE ARE TWO DIFFERENT SPELLINGS AND CONFLATING THEM IS A BOOKED DEFECT. A grid scope is
|
| 57 |
+
* `customer`; the registry key is `customer_data`. Wave 33 found three inline copies of that map
|
| 58 |
+
* and one of them filed an alert against the wrong topic, 200 OK and silent. `topicForScope` is
|
| 59 |
+
* the one answer, so this file asks it rather than adding a fourth copy.
|
| 60 |
+
*/
|
| 61 |
+
export function starDatabaseKey(scope: string): string {
|
| 62 |
+
return topicForScope(scope).key;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function parse(body: unknown, fallbackDatabase: string): RecordStars {
|
| 66 |
+
const row = (body ?? {}) as Record<string, unknown>;
|
| 67 |
+
const view = row.view && typeof row.view === "object" ? (row.view as SavedView) : null;
|
| 68 |
+
return {
|
| 69 |
+
database: typeof row.database === "string" && row.database ? row.database : fallbackDatabase,
|
| 70 |
+
count: typeof row.count === "number" ? row.count : 0,
|
| 71 |
+
// PRUNE, NEVER INVENT β the same discipline `parseStarred` and `parseNavImportant` follow.
|
| 72 |
+
ids: Array.isArray(row.ids)
|
| 73 |
+
? row.ids.filter((id): id is number => typeof id === "number" && Number.isFinite(id))
|
| 74 |
+
: [],
|
| 75 |
+
view: view && typeof (view as { id?: unknown }).id === "string" ? view : null,
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
async function callStars(path: string, init: RequestInit, database: string):
|
| 80 |
+
Promise<{ ok: true; value: RecordStars } | { ok: false; message: string }> {
|
| 81 |
+
let res: Response;
|
| 82 |
+
try {
|
| 83 |
+
res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 84 |
+
} catch {
|
| 85 |
+
return { ok: false, message: "Cannot reach the server." };
|
| 86 |
+
}
|
| 87 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 88 |
+
if (!res.ok) {
|
| 89 |
+
const detail = (body as { error?: { message?: string } } | null)?.error?.message;
|
| 90 |
+
return {
|
| 91 |
+
ok: false,
|
| 92 |
+
message: detail && res.status < 500
|
| 93 |
+
? detail
|
| 94 |
+
: "Something went wrong on our side. The star was not saved.",
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
return { ok: true, value: parse(body, database) };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function loadRecordStars(database: string) {
|
| 101 |
+
return callStars(`/starred/records?database=${encodeURIComponent(database)}`, {}, database);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export function setRecordStar(database: string, id: number, on: boolean) {
|
| 105 |
+
return callStars("/starred/records", {
|
| 106 |
+
method: "POST",
|
| 107 |
+
headers: { "Content-Type": "application/json" },
|
| 108 |
+
// β THE ID GOES AS A STRING OF DIGITS. C5 names a NON-NUMERIC id as a 400, and a pid is a
|
| 109 |
+
// number here β `String(id)` is the conversion, not `id.toString(36)` or a template with a
|
| 110 |
+
// prefix. The route's own 400 is the wall; this is just not asking for it.
|
| 111 |
+
body: JSON.stringify({ database, id: String(id), on }),
|
| 112 |
+
}, database);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* β The grid's record stars, read once per database open.
|
| 117 |
+
*
|
| 118 |
+
* β IT IS NOT MOUNT-BLOCKING AND IT MUST NOT BECOME SO. This is one small read, unlike
|
| 119 |
+
* `GET /starred` (E measured that one at 7,331 ms cold because it scans every visible database's
|
| 120 |
+
* view bucket) β but the grid paints rows long before this answers, and gating the table on it
|
| 121 |
+
* would trade a real table for a spinner over a decoration.
|
| 122 |
+
*
|
| 123 |
+
* β THE GENERATION GUARD IS NOT OPTIONAL. Two clicks in quick succession, or a database switch
|
| 124 |
+
* mid-flight, leave two responses racing and the slower one wins; the counter is what makes the
|
| 125 |
+
* last INTENT win instead of the last packet. Same shape `useStarred` uses, for the same reason.
|
| 126 |
+
*/
|
| 127 |
+
export function useRecordStars(scope: string, enabled: boolean): {
|
| 128 |
+
stars: RecordStars;
|
| 129 |
+
ready: boolean;
|
| 130 |
+
starred: (pid: number) => boolean;
|
| 131 |
+
toggle: (pid: number, on: boolean) => void;
|
| 132 |
+
} {
|
| 133 |
+
const database = starDatabaseKey(scope);
|
| 134 |
+
const [stars, setStars] = useState<RecordStars>(NO_RECORD_STARS);
|
| 135 |
+
const [ready, setReady] = useState(false);
|
| 136 |
+
const gen = useRef(0);
|
| 137 |
+
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
if (!enabled) return;
|
| 140 |
+
const mine = gen.current + 1;
|
| 141 |
+
gen.current = mine;
|
| 142 |
+
setReady(false);
|
| 143 |
+
void loadRecordStars(database).then((result) => {
|
| 144 |
+
if (gen.current !== mine) return;
|
| 145 |
+
// β A FAILED READ IS NOT AN EMPTY LIST, but here it degrades to one DELIBERATELY and
|
| 146 |
+
// silently: a toast on every grid open because a decoration could not load would be worse
|
| 147 |
+
// than the missing stars. `ready` stays false, so no control is drawn and nobody is told
|
| 148 |
+
// something is starred when we do not know. A failed WRITE is loud β see `toggle`.
|
| 149 |
+
if (result.ok) {
|
| 150 |
+
setStars(result.value);
|
| 151 |
+
setReady(true);
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
}, [database, enabled]);
|
| 155 |
+
|
| 156 |
+
const toggle = useCallback((pid: number, on: boolean) => {
|
| 157 |
+
const mine = gen.current + 1;
|
| 158 |
+
gen.current = mine;
|
| 159 |
+
// Optimistic, then reconciled from the server's own answer β which is also what carries the
|
| 160 |
+
// updated projection view, so the rail row appears on the first star with no second request.
|
| 161 |
+
setStars((cur) => ({
|
| 162 |
+
...cur,
|
| 163 |
+
ids: on ? [...cur.ids, pid] : cur.ids.filter((id) => id !== pid),
|
| 164 |
+
count: on ? cur.count + 1 : Math.max(0, cur.count - 1),
|
| 165 |
+
}));
|
| 166 |
+
void setRecordStar(database, pid, on).then((result) => {
|
| 167 |
+
if (gen.current !== mine) return;
|
| 168 |
+
if (result.ok) {
|
| 169 |
+
setStars(result.value);
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
// β A REFUSED WRITE SAYS SO AND PUTS THE LIST BACK. Leaving the optimistic flip on screen is
|
| 173 |
+
// the worst of the three options: the reader believes a star was saved that was not, and
|
| 174 |
+
// finds out on their next reload [[lost-write-looks-like-failed-read]]. The 400 this most
|
| 175 |
+
// often carries is `starred_full` past 500, whose message names its own cause.
|
| 176 |
+
signal(TOAST_EVENT, result.message);
|
| 177 |
+
void loadRecordStars(database).then((again) => {
|
| 178 |
+
if (again.ok) setStars(again.value);
|
| 179 |
+
});
|
| 180 |
+
});
|
| 181 |
+
}, [database]);
|
| 182 |
+
|
| 183 |
+
const starred = useCallback(
|
| 184 |
+
(pid: number) => stars.ids.includes(pid), [stars.ids]);
|
| 185 |
+
|
| 186 |
+
return { stars, ready, starred, toggle };
|
| 187 |
+
}
|
web/src/customer-grid/rowStar.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ---------------------------------------------------------------------------
|
| 2 |
+
customer-grid / rowStar.css β W35-T29 (rulings R5, R6), the record star.
|
| 3 |
+
|
| 4 |
+
β ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
|
| 5 |
+
control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
|
| 6 |
+
new surface brings its own stylesheet (the wave's own rule) and a change to an existing
|
| 7 |
+
`.cg-*` rule is that lane's ticket.
|
| 8 |
+
|
| 9 |
+
β IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β never by a lazy
|
| 10 |
+
parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
|
| 11 |
+
imported only by a `lazy()` page while the component drawing its classes shipped in another
|
| 12 |
+
chunk, so the rail painted unstyled for a network round trip.
|
| 13 |
+
--------------------------------------------------------------------------- */
|
| 14 |
+
|
| 15 |
+
/* The hover control at the LEFT end of the primary cell, mirroring the Expand button at its
|
| 16 |
+
right end. Both are real DOM buttons laid over the canvas at glide's own bounds, so the rect
|
| 17 |
+
IS the hit test and there is no second copy of the geometry to drift from the drawing. */
|
| 18 |
+
.cg-row-star {
|
| 19 |
+
position: fixed;
|
| 20 |
+
z-index: 55;
|
| 21 |
+
display: inline-flex;
|
| 22 |
+
align-items: center;
|
| 23 |
+
justify-content: center;
|
| 24 |
+
padding: 0;
|
| 25 |
+
border: 1px solid transparent;
|
| 26 |
+
border-radius: var(--lp-r-sm);
|
| 27 |
+
background: transparent;
|
| 28 |
+
color: var(--lp-muted);
|
| 29 |
+
cursor: pointer;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* β A STARRED ROW'S MARK IS NOT HOVER-ONLY. The hover control is how you SET one; a row that is
|
| 33 |
+
already starred has to say so while the pointer is elsewhere, or the grid cannot answer the
|
| 34 |
+
question the feature exists for. D-253 is a booked defect whose whole content is "satisfied
|
| 35 |
+
only on hover" β this is that lesson applied to a mark rather than to an explanation.
|
| 36 |
+
The always-on mark is drawn by the same button with `is-on`; the caller keeps it mounted for a
|
| 37 |
+
starred row whether or not the pointer is over it. */
|
| 38 |
+
.cg-row-star.is-on {
|
| 39 |
+
color: var(--lp-yellow-deep);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.cg-row-star:hover {
|
| 43 |
+
border-color: var(--lp-line);
|
| 44 |
+
background: var(--cg-white);
|
| 45 |
+
color: var(--lp-yellow-deep);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.cg-row-star:focus-visible {
|
| 49 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 50 |
+
outline-offset: 1px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.cg-row-star:disabled {
|
| 54 |
+
cursor: default;
|
| 55 |
+
opacity: 0.55;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* The drawer's own copy of the control (`RecordDetail`), which is in the flow rather than over a
|
| 59 |
+
canvas β so it takes the colour rules above and none of the positioning. */
|
| 60 |
+
.cg-detail-star {
|
| 61 |
+
display: inline-flex;
|
| 62 |
+
align-items: center;
|
| 63 |
+
justify-content: center;
|
| 64 |
+
width: 28px;
|
| 65 |
+
height: 28px;
|
| 66 |
+
padding: 0;
|
| 67 |
+
border: 1px solid transparent;
|
| 68 |
+
border-radius: var(--lp-r-sm);
|
| 69 |
+
background: transparent;
|
| 70 |
+
color: var(--lp-muted);
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.cg-detail-star.is-on { color: var(--lp-yellow-deep); }
|
| 75 |
+
.cg-detail-star:hover { border-color: var(--lp-line); color: var(--lp-yellow-deep); }
|
| 76 |
+
.cg-detail-star:disabled { cursor: default; opacity: 0.55; }
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -2772,11 +2772,24 @@ export interface ViewConfig {
|
|
| 2772 |
*/
|
| 2773 |
cohortLock?: string;
|
| 2774 |
/**
|
| 2775 |
-
* ββ WAVE 32 Β· T24 Β· CONTRACT C4 (owner item 17, ruling R5) β **
|
| 2776 |
*
|
| 2777 |
* The view carries a live count of its own matching records, in the red accent, beside its
|
| 2778 |
* name in the rail.
|
| 2779 |
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2780 |
* β **ONE MARK. There is no `critical`, no severity and no colour variants** β R5 is explicit,
|
| 2781 |
* and `verify_icons`' view-menu scan asserts the absence of the word so a second severity
|
| 2782 |
* cannot arrive by copy-paste. It is a legibility mark, not a lock: it says "keep this number
|
|
@@ -2936,7 +2949,22 @@ export function allViewName(scope: string): string {
|
|
| 2936 |
*/
|
| 2937 |
export const IG_OVERVIEW_VIEW_ID = "tpl_overview";
|
| 2938 |
|
| 2939 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2940 |
|
| 2941 |
// βββββββββββββββββββββββββββββββββββββββββββββββ wave 16 C-TOPIC: the grid's TOPIC
|
| 2942 |
/**
|
|
|
|
| 2772 |
*/
|
| 2773 |
cohortLock?: string;
|
| 2774 |
/**
|
| 2775 |
+
* ββ WAVE 32 Β· T24 Β· CONTRACT C4 (owner item 17, W32 ruling R5) β **the star.**
|
| 2776 |
*
|
| 2777 |
* The view carries a live count of its own matching records, in the red accent, beside its
|
| 2778 |
* name in the rail.
|
| 2779 |
*
|
| 2780 |
+
* ββ **THE KEY IS `important` AND THE WORD ON SCREEN IS "STAR", AND THAT MISMATCH IS
|
| 2781 |
+
* DELIBERATE (W35-T26, owner item 9 / R4).** Owner: star and mark-important are ONE idea and
|
| 2782 |
+
* ONE flag; the label retires, the flag does not. Renaming this key would create exactly the
|
| 2783 |
+
* second store R4 forbids, and every view already marked would lose its star silently β the
|
| 2784 |
+
* server's allowlist reads `cfg.get('important')` by name.
|
| 2785 |
+
* β So `POST /api/v1/starred {kind: "view"}` (contract C2) resolves to THIS key rather than to
|
| 2786 |
+
* a store of its own, and E asserts that in `api_api` with a control that looks for a second
|
| 2787 |
+
* one. If you are about to add a `starred?: boolean` beside this, stop and re-read R4.
|
| 2788 |
+
* β AND THE SCOPE IS NOT WHAT THE SCREEN SUGGESTS: this flag lives on the VIEW RECORD, and a
|
| 2789 |
+
* SHARED view has exactly one record β so starring a shared view stars it for everybody who can
|
| 2790 |
+
* see it, and any collaborator can clear it (mailbox E-3). A RECORD star is per user; a shared
|
| 2791 |
+
* view's star is tenant-wide. Two scopes behind one word.
|
| 2792 |
+
*
|
| 2793 |
* β **ONE MARK. There is no `critical`, no severity and no colour variants** β R5 is explicit,
|
| 2794 |
* and `verify_icons`' view-menu scan asserts the absence of the word so a second severity
|
| 2795 |
* cannot arrive by copy-paste. It is a legibility mark, not a lock: it says "keep this number
|
|
|
|
| 2949 |
*/
|
| 2950 |
export const IG_OVERVIEW_VIEW_ID = "tpl_overview";
|
| 2951 |
|
| 2952 |
+
/**
|
| 2953 |
+
* ββ W35-T30 Β· CONTRACT C5 (R5) β the per-user "Starred records" projection.
|
| 2954 |
+
*
|
| 2955 |
+
* β THE SERVER MINTS IT AND THIS SIDE ONLY RECOGNISES IT. `routes_starred.STARRED_VIEW_ID`
|
| 2956 |
+
* carries the same literal and hands back the WHOLE view object (id, name, `kind: "system"`,
|
| 2957 |
+
* `locked: true`, `config.memberPids`), so the rail never builds a second idea of what this view
|
| 2958 |
+
* is. The constant is here because the UNDELETABLE set below is what makes it undeletable, on the
|
| 2959 |
+
* same two facts that protect `all-customers`.
|
| 2960 |
+
*
|
| 2961 |
+
* β ITS ROW SET IS CURATED (`memberPids`), which is also the ONE shape
|
| 2962 |
+
* `routes_nav._view_record_count` counts for FREE β so a real number can sit beside it on Home
|
| 2963 |
+
* and in Starred for nothing, rather than a dash and an excuse (contract C3).
|
| 2964 |
+
*/
|
| 2965 |
+
export const STARRED_VIEW_ID = "starred-records";
|
| 2966 |
+
|
| 2967 |
+
export const UNDELETABLE_VIEW_IDS = new Set([ALL_VIEW_ID, IG_OVERVIEW_VIEW_ID, STARRED_VIEW_ID]);
|
| 2968 |
|
| 2969 |
// βββββββββββββββββββββββββββββββββββββββββββββββ wave 16 C-TOPIC: the grid's TOPIC
|
| 2970 |
/**
|
web/src/home/HomePage.tsx
CHANGED
|
@@ -31,6 +31,13 @@ import { AGENTS_MODULE_LABEL } from "../inbox/inboxModel";
|
|
| 31 |
import { Mark } from "../shell/Brand";
|
| 32 |
import { dbChipClass } from "../shell/nav";
|
| 33 |
import type { DatabaseEntry, Recent } from "../shell/nav";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
import {
|
| 35 |
HOME_LAYOUT_KEY,
|
| 36 |
allDatabases,
|
|
@@ -82,13 +89,26 @@ function NewDbIcon() {
|
|
| 82 |
* silently painted LAST WAVE'S BRAND while a comment claimed parity.
|
| 83 |
*/
|
| 84 |
|
| 85 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
function ConnectIcon() {
|
| 87 |
return (
|
| 88 |
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 89 |
-
<path d="M6.
|
| 90 |
-
<path d="
|
| 91 |
-
<path d="M8
|
| 92 |
</svg>
|
| 93 |
);
|
| 94 |
}
|
|
@@ -144,15 +164,9 @@ function QuickCard({
|
|
| 144 |
);
|
| 145 |
}
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
automations,
|
| 151 |
-
onTemplates,
|
| 152 |
-
onNewDatabase,
|
| 153 |
-
onConnectors,
|
| 154 |
-
onOpenAutomation,
|
| 155 |
-
}: {
|
| 156 |
/**
|
| 157 |
* The SHAPED, server-filtered nav. The only thing a tile may be drawn from.
|
| 158 |
*
|
|
@@ -183,6 +197,54 @@ export default function HomePage({
|
|
| 183 |
onConnectors: () => void;
|
| 184 |
/** Open one automation: the frame owns the hash, the surface owns which one is selected. */
|
| 185 |
onOpenAutomation: (id: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}) {
|
| 187 |
const [layout, setLayout] = useState<HomeLayout>(() => {
|
| 188 |
try {
|
|
@@ -208,6 +270,23 @@ export default function HomePage({
|
|
| 208 |
const now = Math.floor(Date.now() / 1000);
|
| 209 |
const databases = allDatabases(entries, recents, now);
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
return (
|
| 212 |
<div className="shell-home">
|
| 213 |
<h1 className="home-title">Home</h1>
|
|
@@ -306,8 +385,12 @@ export default function HomePage({
|
|
| 306 |
<h2 className="home-section-title">{section.title}</h2>
|
| 307 |
<div className={"home-tiles is-" + layout}>
|
| 308 |
{section.tiles.map((tile) => (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
<a
|
| 310 |
-
key={tile.key}
|
| 311 |
className="home-tile"
|
| 312 |
href={tile.href}
|
| 313 |
{...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
|
|
@@ -342,6 +425,15 @@ export default function HomePage({
|
|
| 342 |
{tile.ago ? <span className="home-tile-ago">{tile.ago}</span> : null}
|
| 343 |
</span>
|
| 344 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
))}
|
| 346 |
</div>
|
| 347 |
</section>
|
|
@@ -382,8 +474,8 @@ export default function HomePage({
|
|
| 382 |
<h2 className="home-section-title">{AGENTS_MODULE_LABEL}</h2>
|
| 383 |
<div className={"home-tiles is-" + layout}>
|
| 384 |
{automations.map((a) => (
|
|
|
|
| 385 |
<button
|
| 386 |
-
key={a.id}
|
| 387 |
type="button"
|
| 388 |
className="home-tile home-tile-auto"
|
| 389 |
onClick={() => onOpenAutomation(a.id)}
|
|
@@ -400,6 +492,53 @@ export default function HomePage({
|
|
| 400 |
{a.sub ? <span className="home-tile-ago">{a.sub}</span> : null}
|
| 401 |
</span>
|
| 402 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
))}
|
| 404 |
</div>
|
| 405 |
</section>
|
|
@@ -407,3 +546,28 @@ export default function HomePage({
|
|
| 407 |
</div>
|
| 408 |
);
|
| 409 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
import { Mark } from "../shell/Brand";
|
| 32 |
import { dbChipClass } from "../shell/nav";
|
| 33 |
import type { DatabaseEntry, Recent } from "../shell/nav";
|
| 34 |
+
// β WAVE 35 Β· W35-T15 (R4, C2) β ONE STAR CONTROL, drawn in the module that owns the concept and
|
| 35 |
+
// worn by both surfaces. R4 makes star and "mark important" one idea and one flag; a second star
|
| 36 |
+
// component here would be the "one mark, one meaning" failure DESIGN.md 4 names, and `useStarred`
|
| 37 |
+
// is what keeps Home and Starred from ending up with different ideas of what is starred.
|
| 38 |
+
import { StarButton, Tile } from "../starred/StarredPage";
|
| 39 |
+
import { shapeStarred, useStarred, viewCount } from "../starred/starredApi";
|
| 40 |
+
import type { StarKind, Starred, StarredCounts, StarredLoad } from "../starred/starredApi";
|
| 41 |
import {
|
| 42 |
HOME_LAYOUT_KEY,
|
| 43 |
allDatabases,
|
|
|
|
| 89 |
* silently painted LAST WAVE'S BRAND while a comment claimed parity.
|
| 90 |
*/
|
| 91 |
|
| 92 |
+
/**
|
| 93 |
+
* Connect a source β a two-prong plug with a cord, which is the thing you put in an outlet.
|
| 94 |
+
*
|
| 95 |
+
* β WAVE 35 Β· W35-T15 (owner item 5): *"change the Icon for the connector to look like the cable
|
| 96 |
+
* head that you would put in an electrical outlet."* It drew two links of a CHAIN until now, and
|
| 97 |
+
* so does its twin on the rail (`shell/Shell.tsx::PlugIcon`, whose NAME has been wrong since wave
|
| 98 |
+
* 23). The prongs are the load-bearing part of the drawing; the cord is what stops it reading as
|
| 99 |
+
* a padlock at 16px.
|
| 100 |
+
*
|
| 101 |
+
* β THE TWO COPIES MUST NOT DIVERGE and they are in two different fences, so the geometry below
|
| 102 |
+
* was published in mailbox B-5 as an ASK to the session that owns the rail (W35-T04) rather than
|
| 103 |
+
* changed here and hoped about. One glyph, one meaning; two plugs that disagree is worse than the
|
| 104 |
+
* chain, because at least the chain was wrong in both places identically.
|
| 105 |
+
*/
|
| 106 |
function ConnectIcon() {
|
| 107 |
return (
|
| 108 |
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 109 |
+
<path d="M6.1 5.2V2.1M9.9 5.2V2.1" />
|
| 110 |
+
<path d="M12.1 5.2v3.1a2.7 2.7 0 0 1-2.7 2.7H6.6a2.7 2.7 0 0 1-2.7-2.7V5.2Z" />
|
| 111 |
+
<path d="M8 11v1.6a1.6 1.6 0 0 0 1.6 1.6h1.5" />
|
| 112 |
</svg>
|
| 113 |
);
|
| 114 |
}
|
|
|
|
| 164 |
);
|
| 165 |
}
|
| 166 |
|
| 167 |
+
/** What the FRAME hands Home. Unchanged by the wave-35 split, and named so both halves take
|
| 168 |
+
* exactly the same shape rather than drifting. */
|
| 169 |
+
export interface HomeProps {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
/**
|
| 171 |
* The SHAPED, server-filtered nav. The only thing a tile may be drawn from.
|
| 172 |
*
|
|
|
|
| 197 |
onConnectors: () => void;
|
| 198 |
/** Open one automation: the frame owns the hash, the surface owns which one is selected. */
|
| 199 |
onOpenAutomation: (id: string) => void;
|
| 200 |
+
/**
|
| 201 |
+
* β WAVE 35 Β· W35-T16 (owner item 9) β open a starred VIEW inside its database.
|
| 202 |
+
*
|
| 203 |
+
* β REQUIRED, and the frame's, for the same two reasons `onOpenAutomation` is. Optional would
|
| 204 |
+
* degrade to "clicking a starred view does nothing", indistinguishable from the section never
|
| 205 |
+
* having been built and red in no gate. And the act is a hash change PLUS a request to the grid
|
| 206 |
+
* for which view is selected: the grid's listener DROPS a viewId it has not loaded yet with no
|
| 207 |
+
* acknowledgement, so the emit needs the frame's bounded retry ladder against a MEASURED worst
|
| 208 |
+
* case, not a single hopeful dispatch.
|
| 209 |
+
* β `Shell.tsx` passes the IDENTICAL handler to Home and to Starred (mailbox A, `DONE B-8`), so
|
| 210 |
+
* a view opened from one cannot behave differently from the same view opened from the other.
|
| 211 |
+
*/
|
| 212 |
+
onOpenView: (database: string, viewId: string) => void;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* β WAVE 35 Β· W35-T15 β THE PURE HALF, AND IT EXISTS SO HOME CAN BE LOOKED AT.
|
| 217 |
+
*
|
| 218 |
+
* Home held no state that could not be rendered until the star list arrived. `renderToStaticMarkup`
|
| 219 |
+
* never runs an effect, so a static render of the fetching component can only ever photograph the
|
| 220 |
+
* state where the stars are still unknown, i.e. the one state with no star in it. Splitting the
|
| 221 |
+
* read off is what makes "hover reveals an outline star, a starred tile shows a filled one" a thing
|
| 222 |
+
* a person can SEE without a server. `inbox/_inbox_shot.tsx` paid for this lesson first; the same
|
| 223 |
+
* split is now on `StarredSurface`.
|
| 224 |
+
*
|
| 225 |
+
* β THE DEFAULT EXPORT'S PROPS ARE UNCHANGED, so the frame's mount is untouched by this.
|
| 226 |
+
*/
|
| 227 |
+
export function HomeSurface({
|
| 228 |
+
entries,
|
| 229 |
+
recents,
|
| 230 |
+
automations,
|
| 231 |
+
onTemplates,
|
| 232 |
+
onNewDatabase,
|
| 233 |
+
onConnectors,
|
| 234 |
+
onOpenAutomation,
|
| 235 |
+
onOpenView,
|
| 236 |
+
starLoad,
|
| 237 |
+
starred,
|
| 238 |
+
busy,
|
| 239 |
+
counts,
|
| 240 |
+
toggle,
|
| 241 |
+
}: HomeProps & {
|
| 242 |
+
starLoad: StarredLoad;
|
| 243 |
+
starred: Starred;
|
| 244 |
+
busy: boolean;
|
| 245 |
+
/** C3's row counts, or `null` while the second read is in flight (R7: after paint, never on nav). */
|
| 246 |
+
counts: StarredCounts | null;
|
| 247 |
+
toggle: (kind: StarKind, id: string, on: boolean, database?: string) => void;
|
| 248 |
}) {
|
| 249 |
const [layout, setLayout] = useState<HomeLayout>(() => {
|
| 250 |
try {
|
|
|
|
| 270 |
const now = Math.floor(Date.now() / 1000);
|
| 271 |
const databases = allDatabases(entries, recents, now);
|
| 272 |
|
| 273 |
+
// β THE STAR CONTROL IS ABSENT UNTIL THE LIST ARRIVES, deliberately. Drawing an empty star
|
| 274 |
+
// before the list is known would STATE that nothing is starred and then flip under the reader
|
| 275 |
+
// once it lands. An affordance that appears a moment later is a smaller cost than a control
|
| 276 |
+
// that was wrong for a moment, and this one is revealed on hover anyway.
|
| 277 |
+
const known = starLoad.phase === "ready";
|
| 278 |
+
const dbStarred = new Set(starred.databases);
|
| 279 |
+
const agentStarred = new Set(starred.agents);
|
| 280 |
+
|
| 281 |
+
// β WAVE 35 Β· W35-T16 (owner item 9) β the starred VIEWS, resolved by the SAME function
|
| 282 |
+
// Starred uses. Item 9 asks for these views on Home and under Starred; running them through one
|
| 283 |
+
// resolver is what stops the two lists disagreeing about which views exist or what they are
|
| 284 |
+
// called, which is item 11's complaint applied before it can happen.
|
| 285 |
+
// β AND THE DROP RULE COMES WITH IT: `shapeStarred` refuses a view whose database is not in
|
| 286 |
+
// `entries`, so a star that outlived its database (deleted, or a grant revoked) draws NOTHING
|
| 287 |
+
// rather than a tile that lands the reader in a 403.
|
| 288 |
+
const starredViews = shapeStarred(starred, entries, automations).views;
|
| 289 |
+
|
| 290 |
return (
|
| 291 |
<div className="shell-home">
|
| 292 |
<h1 className="home-title">Home</h1>
|
|
|
|
| 385 |
<h2 className="home-section-title">{section.title}</h2>
|
| 386 |
<div className={"home-tiles is-" + layout}>
|
| 387 |
{section.tiles.map((tile) => (
|
| 388 |
+
// β THE WRAPPER IS ALWAYS PRESENT, star or no star, so the grid cell is the same
|
| 389 |
+
// box in both states and the page does not re-lay-itself out when the star list
|
| 390 |
+
// lands. `.st-tilewrap` is a NEW rule in `starred/starred.css` scoped to itself;
|
| 391 |
+
// `.home-tile`'s own rule in `index.css` is untouched (that file is A's).
|
| 392 |
+
<div key={tile.key} className="st-tilewrap">
|
| 393 |
<a
|
|
|
|
| 394 |
className="home-tile"
|
| 395 |
href={tile.href}
|
| 396 |
{...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
|
|
|
|
| 425 |
{tile.ago ? <span className="home-tile-ago">{tile.ago}</span> : null}
|
| 426 |
</span>
|
| 427 |
</a>
|
| 428 |
+
{known ? (
|
| 429 |
+
<StarButton
|
| 430 |
+
on={dbStarred.has(tile.key)}
|
| 431 |
+
label={tile.label}
|
| 432 |
+
busy={busy}
|
| 433 |
+
onToggle={() => toggle("database", tile.key, !dbStarred.has(tile.key))}
|
| 434 |
+
/>
|
| 435 |
+
) : null}
|
| 436 |
+
</div>
|
| 437 |
))}
|
| 438 |
</div>
|
| 439 |
</section>
|
|
|
|
| 474 |
<h2 className="home-section-title">{AGENTS_MODULE_LABEL}</h2>
|
| 475 |
<div className={"home-tiles is-" + layout}>
|
| 476 |
{automations.map((a) => (
|
| 477 |
+
<div key={a.id} className="st-tilewrap">
|
| 478 |
<button
|
|
|
|
| 479 |
type="button"
|
| 480 |
className="home-tile home-tile-auto"
|
| 481 |
onClick={() => onOpenAutomation(a.id)}
|
|
|
|
| 492 |
{a.sub ? <span className="home-tile-ago">{a.sub}</span> : null}
|
| 493 |
</span>
|
| 494 |
</button>
|
| 495 |
+
{known ? (
|
| 496 |
+
<StarButton
|
| 497 |
+
on={agentStarred.has(a.id)}
|
| 498 |
+
label={a.name}
|
| 499 |
+
busy={busy}
|
| 500 |
+
onToggle={() => toggle("agent", a.id, !agentStarred.has(a.id))}
|
| 501 |
+
/>
|
| 502 |
+
) : null}
|
| 503 |
+
</div>
|
| 504 |
+
))}
|
| 505 |
+
</div>
|
| 506 |
+
</section>
|
| 507 |
+
) : null}
|
| 508 |
+
|
| 509 |
+
{/* β WAVE 35 Β· W35-T16 (owner item 9) β THE STARRED VIEWS.
|
| 510 |
+
Owner: *"remove [the important count] anywhere else but the View itself β¦ Instead, let's
|
| 511 |
+
add these Views under 'Home' and β¦ under 'Starred' module as well."* So the badge leaves
|
| 512 |
+
the rail and the flyout (A's W35-T07, E's W35-T43) and the VIEWS themselves arrive here.
|
| 513 |
+
|
| 514 |
+
β IT IS THE SAME `Tile` COMPONENT `StarredPage` DRAWS, not a copy that agrees today.
|
| 515 |
+
Item 11's complaint is that what is shown twice goes out of step, and two renderers for
|
| 516 |
+
one object is how that starts. The chip, the two lines and the star are one piece of code.
|
| 517 |
+
|
| 518 |
+
β EMPTY RENDERS NOTHING, like every other section here: no starred views is a real
|
| 519 |
+
answer, and a heading over nothing says "there should be something here", which is a
|
| 520 |
+
different and false statement. */}
|
| 521 |
+
{starredViews.length ? (
|
| 522 |
+
<section className="home-section">
|
| 523 |
+
<h2 className="home-section-title">Starred views</h2>
|
| 524 |
+
<div className={"home-tiles is-" + layout}>
|
| 525 |
+
{starredViews.map((row) => (
|
| 526 |
+
<Tile
|
| 527 |
+
key={row.id}
|
| 528 |
+
row={row}
|
| 529 |
+
count={viewCount(row.id, counts)}
|
| 530 |
+
star={
|
| 531 |
+
known ? (
|
| 532 |
+
<StarButton
|
| 533 |
+
on
|
| 534 |
+
label={row.label}
|
| 535 |
+
busy={busy}
|
| 536 |
+
onToggle={() => toggle("view", row.id, false, row.database)}
|
| 537 |
+
/>
|
| 538 |
+
) : undefined
|
| 539 |
+
}
|
| 540 |
+
onOpen={() => onOpenView(row.database, row.id)}
|
| 541 |
+
/>
|
| 542 |
))}
|
| 543 |
</div>
|
| 544 |
</section>
|
|
|
|
| 546 |
</div>
|
| 547 |
);
|
| 548 |
}
|
| 549 |
+
|
| 550 |
+
/**
|
| 551 |
+
* The page the frame mounts. Its props are exactly what they were before wave 35.
|
| 552 |
+
*
|
| 553 |
+
* β THE ONE READ HOME MAKES, and it is deliberately the SAME hook Starred uses. Two copies of
|
| 554 |
+
* "flip it, post it, take the server's answer, put it back if it was refused" is
|
| 555 |
+
* [[one-question-two-normalizers]] with the divergence on screen: one surface optimistic, the
|
| 556 |
+
* other reconciled, and the two disagreeing about what is starred.
|
| 557 |
+
* β It reads AFTER PAINT (the hook's own `useEffect`, never `useLayoutEffect`), because
|
| 558 |
+
* `GET /starred` scans a view bucket per database β C2 forbids a second store for the flag β and
|
| 559 |
+
* was measured at 6.2 ms warm and 7,331 ms COLD on tenant #0 (mailbox E-2).
|
| 560 |
+
*/
|
| 561 |
+
export default function HomePage(props: HomeProps) {
|
| 562 |
+
const { load: starLoad, starred, busy, counts, toggle } = useStarred();
|
| 563 |
+
return (
|
| 564 |
+
<HomeSurface
|
| 565 |
+
{...props}
|
| 566 |
+
starLoad={starLoad}
|
| 567 |
+
starred={starred}
|
| 568 |
+
busy={busy}
|
| 569 |
+
counts={counts}
|
| 570 |
+
toggle={toggle}
|
| 571 |
+
/>
|
| 572 |
+
);
|
| 573 |
+
}
|
web/src/index.css
CHANGED
|
@@ -317,12 +317,21 @@ body {
|
|
| 317 |
/* Item 5 β the rail's head is the shared top band: same height as the shell head and the
|
| 318 |
toolbar, closed by the same hairline. The negative margin bleeds the line to the rail's
|
| 319 |
true edges past the container padding (overflow:hidden clips the collapsed overshoot). */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
.cg-views-top {
|
| 321 |
flex: 0 0 var(--lp-rail-head-h);
|
| 322 |
height: var(--lp-rail-head-h);
|
| 323 |
display: flex;
|
| 324 |
align-items: center;
|
| 325 |
-
justify-content: flex-
|
| 326 |
margin: 0 -8px;
|
| 327 |
padding: 0 8px;
|
| 328 |
border-bottom: 1px solid var(--lp-line);
|
|
@@ -2947,8 +2956,15 @@ body {
|
|
| 2947 |
}
|
| 2948 |
|
| 2949 |
/* ---- the frame ----------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2950 |
.shell-root {
|
| 2951 |
display: flex;
|
|
|
|
| 2952 |
width: 100%;
|
| 2953 |
height: 100vh;
|
| 2954 |
overflow: hidden;
|
|
@@ -2956,6 +2972,51 @@ body {
|
|
| 2956 |
color: var(--lp-ink);
|
| 2957 |
background: var(--lp-surface);
|
| 2958 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2959 |
|
| 2960 |
/* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
|
| 2961 |
light-grey borders") β the wash belongs to page bodies, not the rail.
|
|
@@ -2975,51 +3036,31 @@ body {
|
|
| 2975 |
flex-basis var(--lp-fold-t) var(--lp-fold-e);
|
| 2976 |
}
|
| 2977 |
|
| 2978 |
-
/*
|
| 2979 |
-
|
| 2980 |
-
|
| 2981 |
-
|
|
|
|
| 2982 |
.shell-side-head {
|
| 2983 |
-
display: flex;
|
| 2984 |
-
align-items: center;
|
| 2985 |
-
justify-content: space-between;
|
| 2986 |
flex: 0 0 var(--lp-rail-head-h);
|
| 2987 |
height: var(--lp-rail-head-h);
|
| 2988 |
-
padding: 0 10px 0 16px;
|
| 2989 |
border-bottom: 1px solid var(--lp-line);
|
| 2990 |
-
transition: padding var(--lp-fold-t) var(--lp-fold-e);
|
| 2991 |
-
}
|
| 2992 |
-
.shell-rail-toggle {
|
| 2993 |
-
width: 28px;
|
| 2994 |
-
height: 28px;
|
| 2995 |
-
flex: 0 0 28px;
|
| 2996 |
-
display: inline-flex;
|
| 2997 |
-
align-items: center;
|
| 2998 |
-
justify-content: center;
|
| 2999 |
-
border: none;
|
| 3000 |
-
border-radius: var(--lp-r-md);
|
| 3001 |
-
background: transparent;
|
| 3002 |
-
color: var(--lp-muted);
|
| 3003 |
-
cursor: pointer;
|
| 3004 |
-
}
|
| 3005 |
-
.shell-rail-toggle:hover {
|
| 3006 |
-
background: var(--lp-surface-2);
|
| 3007 |
-
color: var(--lp-ink);
|
| 3008 |
-
}
|
| 3009 |
-
.shell-rail-toggle:focus-visible {
|
| 3010 |
-
outline: 2px solid var(--lp-blue-deep);
|
| 3011 |
-
outline-offset: 1px;
|
| 3012 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3013 |
.shell-side.is-collapsed {
|
| 3014 |
width: var(--lp-rail-min);
|
| 3015 |
flex-basis: var(--lp-rail-min);
|
| 3016 |
}
|
| 3017 |
-
/*
|
| 3018 |
-
|
| 3019 |
-
|
| 3020 |
-
and the icons SLIDE to the strip's center as the width animates (a justify-content flip
|
| 3021 |
-
would snap them at t=0). Mark 26px β (48-26)/2 = 11; icon x: 6 + 10 = 16 = (48-16)/2. */
|
| 3022 |
-
.shell-side.is-collapsed .shell-side-head { padding: 0 11px; }
|
| 3023 |
.shell-side.is-collapsed .shell-brand { gap: 0; }
|
| 3024 |
.shell-side.is-collapsed .lp-wordmark,
|
| 3025 |
.shell-side.is-collapsed .shell-nav-label,
|
|
@@ -3039,33 +3080,35 @@ body {
|
|
| 3039 |
gap: 0;
|
| 3040 |
}
|
| 3041 |
.shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 10px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3042 |
.shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
|
| 3043 |
.shell-side.is-collapsed .shell-account {
|
| 3044 |
padding: 7px 8px;
|
| 3045 |
gap: 0;
|
| 3046 |
}
|
| 3047 |
|
| 3048 |
-
/*
|
| 3049 |
-
|
| 3050 |
-
|
| 3051 |
-
|
| 3052 |
-
|
| 3053 |
-
padding: 0;
|
| 3054 |
-
margin: 0;
|
| 3055 |
-
border: none;
|
| 3056 |
-
background: none;
|
| 3057 |
-
color: inherit;
|
| 3058 |
-
font: inherit;
|
| 3059 |
-
cursor: pointer;
|
| 3060 |
-
min-width: 0;
|
| 3061 |
-
}
|
| 3062 |
-
.shell-brand-btn:disabled { cursor: default; }
|
| 3063 |
-
.shell-brand-btn:not(:disabled):hover { opacity: 0.8; }
|
| 3064 |
-
.shell-brand-btn:focus-visible {
|
| 3065 |
-
outline: 2px solid var(--lp-blue-deep);
|
| 3066 |
-
outline-offset: 2px;
|
| 3067 |
-
border-radius: var(--lp-r-sm);
|
| 3068 |
-
}
|
| 3069 |
.shell-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
| 3070 |
/* β DELIBERATELY OFF THE TYPE SCALE. The wordmark is BRAND, not UI text: 17px is what the app's
|
| 3071 |
own nav wordmark resolves to (1.02rem against its 17px base), and matching the sibling door
|
|
@@ -3174,7 +3217,12 @@ body {
|
|
| 3174 |
/* β NOT in the shared rule above: `.shell-nav-group` is a LABEL, not a control
|
| 3175 |
("quieter, no hover, no cursor" β its own rule below), and a pointer cursor
|
| 3176 |
on a row that cannot be clicked promises a destination that does not exist. */
|
| 3177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3178 |
.shell-nav-item:hover { background: var(--lp-surface-2); }
|
| 3179 |
.shell-nav-item.is-child { padding-left: 30px; }
|
| 3180 |
/* The current row is marked the way the host marks it: the selected-row tint,
|
|
@@ -3186,9 +3234,47 @@ body {
|
|
| 3186 |
never had that, because there the ROW carries the tint. It does here now
|
| 3187 |
(`.shell-nav-row.is-active`, navExtras.css). What stays here is everything
|
| 3188 |
that genuinely belongs to the LINK: its weight, and the icon below. */
|
| 3189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3190 |
font-weight: 600;
|
| 3191 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3192 |
.shell-nav-item:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: -2px; }
|
| 3193 |
|
| 3194 |
.shell-nav-icon {
|
|
@@ -7352,18 +7438,20 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 7352 |
It had the height and nothing else: the fold control sat LEFT while the Views rail's sits
|
| 7353 |
right, and there was no hairline at all β so the automation page's top band simply stopped
|
| 7354 |
halfway across the screen while every database page's ran edge to edge. The three things
|
| 7355 |
-
`.cg-views-top` does and this did not: `justify-content
|
| 7356 |
and the negative margin that bleeds that line past the rail's own gutter to its true edges
|
| 7357 |
(`overflow: hidden` on the rail clips the collapsed overshoot, exactly as it does there).
|
| 7358 |
β THE BLEED IS THIS RAIL'S OWN GUTTER, not a copied number: `.cg-views` pads 8px and bleeds
|
| 7359 |
-8px; this rail pads 10px, so it bleeds -10px. Copying the -8 would leave a 2px gap at each
|
| 7360 |
-
end β the same defect in a more convincing disguise.
|
|
|
|
|
|
|
| 7361 |
.auto-rail-top {
|
| 7362 |
flex: 0 0 var(--lp-rail-head-h);
|
| 7363 |
height: var(--lp-rail-head-h);
|
| 7364 |
display: flex;
|
| 7365 |
align-items: center;
|
| 7366 |
-
justify-content: flex-
|
| 7367 |
margin: 0 -10px;
|
| 7368 |
padding: 0 10px;
|
| 7369 |
border-bottom: 1px solid var(--lp-line);
|
|
@@ -10667,7 +10755,11 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 10667 |
/* ββ Item 7 (R9) β the collapsed rail expands from its own background βββββββ
|
| 10668 |
The strip's blank area is now a click target, so it says so. Interactive
|
| 10669 |
children keep their own cursor (the UA default for a link/button is already
|
| 10670 |
-
`pointer`)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10671 |
.shell-side.is-collapsed { cursor: pointer; }
|
| 10672 |
|
| 10673 |
/* ==== /wave20:S4 ==== */
|
|
@@ -10993,9 +11085,24 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 10993 |
/* ββ C10 β HOME βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10994 |
`reference/Airtable Home.png`'s anatomy, our tokens. `.shell-main` is `overflow: hidden`, so
|
| 10995 |
the page owns its own scroll or a tenant with forty recents can reach none of them. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10996 |
.shell-home {
|
| 10997 |
height: 100%;
|
| 10998 |
overflow-y: auto;
|
|
|
|
| 10999 |
padding: 34px 44px 56px;
|
| 11000 |
box-sizing: border-box;
|
| 11001 |
background: var(--lp-wash);
|
|
@@ -11069,12 +11176,17 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 11069 |
border-radius: var(--lp-r-md);
|
| 11070 |
background: var(--lp-surface);
|
| 11071 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11072 |
.home-layout-btn {
|
| 11073 |
display: inline-flex;
|
| 11074 |
align-items: center;
|
| 11075 |
justify-content: center;
|
| 11076 |
-
width:
|
| 11077 |
-
height:
|
| 11078 |
border: 0;
|
| 11079 |
border-radius: var(--lp-r-sm);
|
| 11080 |
background: transparent;
|
|
@@ -11084,11 +11196,18 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 11084 |
cursor: pointer;
|
| 11085 |
}
|
| 11086 |
.home-layout-btn:hover { background: var(--lp-surface-2); }
|
| 11087 |
-
/* Blue = selected/active, the fixed semantic (DESIGN.md Β§3)
|
| 11088 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11089 |
.home-layout-icon {
|
| 11090 |
-
width:
|
| 11091 |
-
height:
|
| 11092 |
fill: none;
|
| 11093 |
stroke: currentColor;
|
| 11094 |
stroke-width: 1.35;
|
|
@@ -12258,50 +12377,24 @@ textarea.cg-json-raw:focus {
|
|
| 12258 |
text-align: center;
|
| 12259 |
line-height: 1;
|
| 12260 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12261 |
/* β WAVE-33 T14 (D-205) β the same mark, saying "no number here, and here is why" (its `title`).
|
| 12262 |
β NOT the solid purple: a filled badge is the product's word for "this many", and wearing it
|
| 12263 |
over a dash would state a count of nothing. Outlined and muted says "the slot exists, the
|
| 12264 |
number does not" β which is exactly the distinction an ABSENT badge could not draw, and the
|
| 12265 |
whole of D-205. Same box, so a rail of marked views does not jump when one of them cannot be
|
| 12266 |
counted. */
|
| 12267 |
-
/* β WAVE-33 T16 (owner item 5) β the DATABASE-level important total, at the top of its own rail.
|
| 12268 |
-
Sits beside the minimize toggle in `.cg-views-top`, which held only that button. `margin-left:
|
| 12269 |
-
auto` pushes it to the right edge so the badge lines up with the per-view badges directly
|
| 12270 |
-
below it β the total and its parts read as one column, which is the whole point of putting it
|
| 12271 |
-
here rather than in the shell's database header. */
|
| 12272 |
-
/* ββ WAVE 34 Β· T20 (R1, contract C1) β THE WORD IS GONE, SO ITS TYPOGRAPHY GOES WITH IT.
|
| 12273 |
-
`gap`, `font-size`, `font-weight` and `color` here only ever painted the literal "Important";
|
| 12274 |
-
with the label removed they style nothing (the badge sets its own), and left in place they are
|
| 12275 |
-
the kind of inert rule a later reader restores a label to fit. What survives is the LAYOUT this
|
| 12276 |
-
wrapper is actually for: `margin-left: auto` pushes it to the right edge so the total lines up
|
| 12277 |
-
with the per-view badges directly below it, and the class name itself is contract C1's hook
|
| 12278 |
-
(`.cg-views-important.is-partial` hangs the dashed-purple partial mark off it, and B's flyout
|
| 12279 |
-
row is named against it). */
|
| 12280 |
-
.cg-views-important {
|
| 12281 |
-
margin-left: auto;
|
| 12282 |
-
display: inline-flex;
|
| 12283 |
-
align-items: center;
|
| 12284 |
-
white-space: nowrap;
|
| 12285 |
-
}
|
| 12286 |
-
/* R1's "SMALLER", as a box rather than as type. β THE FONT CANNOT SHRINK AND THAT IS A REAL
|
| 12287 |
-
CONSTRAINT, NOT AN OMISSION: `--lp-fs-3xs` (11px) is the smallest size the scale declares, and
|
| 12288 |
-
`verify_ui`'s R5 leg reds ANY literal `font-size` in `web/src` CSS, so the only way to go below
|
| 12289 |
-
it is to add a fourth micro size to the type scale for one badge. 20x20 -> 16x16 is a 36% cut
|
| 12290 |
-
in area on top of losing the label, which is what the ruling asked for; inventing a 10px step
|
| 12291 |
-
under the scale's own floor is not. β SCOPED BY PARENT on purpose: `.cg-view-count` is shared
|
| 12292 |
-
with the per-view badges (and with `.is-unknown`), and shrinking it unscoped would resize every
|
| 12293 |
-
count in the rail from a ruling about the header. */
|
| 12294 |
-
.cg-views-important .cg-view-count {
|
| 12295 |
-
min-width: 16px;
|
| 12296 |
-
height: 16px;
|
| 12297 |
-
padding: 0 2px;
|
| 12298 |
-
}
|
| 12299 |
-
/* A PARTIAL total wears the same purple as a whole one β the number it shows IS real, it is just
|
| 12300 |
-
not all of them β and says so with a `+` and its title. Muting it would read as "less
|
| 12301 |
-
important" rather than "less complete". */
|
| 12302 |
-
.cg-views-important.is-partial .cg-view-count {
|
| 12303 |
-
border: 1px dashed var(--lp-purple-deep);
|
| 12304 |
-
}
|
| 12305 |
.cg-view-count.is-unknown {
|
| 12306 |
background: transparent;
|
| 12307 |
color: var(--lp-muted);
|
|
|
|
| 317 |
/* Item 5 β the rail's head is the shared top band: same height as the shell head and the
|
| 318 |
toolbar, closed by the same hairline. The negative margin bleeds the line to the rail's
|
| 319 |
true edges past the container padding (overflow:hidden clips the collapsed overshoot). */
|
| 320 |
+
/* ββ WAVE 35 Β· T11 (owner item 1, R1) β THE FOLD CONTROL LIVES ON THE LEFT EDGE NOW, in every
|
| 321 |
+
rail the product draws (the shell's, this one, and `.auto-rail-top`). The owner's word is that
|
| 322 |
+
the "III" belongs on the edge the navigation OPENS from, not the edge it ends at.
|
| 323 |
+
β THE OLD `flex-end` WAS ONLY HALF OF WHAT PLACED THIS BUTTON, which is why flipping it alone
|
| 324 |
+
is not the whole change: the toggle is already `.cg-views-top`'s FIRST child, and the important
|
| 325 |
+
total that used to follow it carried `margin-left: auto`. An auto margin outranks
|
| 326 |
+
`justify-content` entirely, so on a database with a marked view the toggle was ALREADY hard
|
| 327 |
+
left and on every other database it sat right β one control, two positions, depending on data.
|
| 328 |
+
With the badge gone (W35-T27) and this at `flex-start` there is one position. */
|
| 329 |
.cg-views-top {
|
| 330 |
flex: 0 0 var(--lp-rail-head-h);
|
| 331 |
height: var(--lp-rail-head-h);
|
| 332 |
display: flex;
|
| 333 |
align-items: center;
|
| 334 |
+
justify-content: flex-start;
|
| 335 |
margin: 0 -8px;
|
| 336 |
padding: 0 8px;
|
| 337 |
border-bottom: 1px solid var(--lp-line);
|
|
|
|
| 2956 |
}
|
| 2957 |
|
| 2958 |
/* ---- the frame ----------------------------------------------------------- */
|
| 2959 |
+
/* ββ WAVE 35 Β· T01 (owner item 1, R1, contract C1) β `.shell-root` IS A COLUMN NOW: the top strip,
|
| 2960 |
+
then the rail-and-content row. It was a plain flex ROW (rail | main) and the strip has to span
|
| 2961 |
+
the whole window, so the row it used to be is now `.shell-frame` one level down.
|
| 2962 |
+
β The overlay children that follow `.shell-frame` (`.shell-toast`, `.shell-newdb-scrim`) stay
|
| 2963 |
+
correct through this because both are `position: fixed` and therefore out of flow β checked, not
|
| 2964 |
+
assumed; a STATIC sibling would have become a third row in this column and eaten height. */
|
| 2965 |
.shell-root {
|
| 2966 |
display: flex;
|
| 2967 |
+
flex-direction: column;
|
| 2968 |
width: 100%;
|
| 2969 |
height: 100vh;
|
| 2970 |
overflow: hidden;
|
|
|
|
| 2972 |
color: var(--lp-ink);
|
| 2973 |
background: var(--lp-surface);
|
| 2974 |
}
|
| 2975 |
+
/* C1: the ONE persistent band. Full window width, on every module, and nothing in it moves when
|
| 2976 |
+
you navigate β which is the whole of ruling R1. */
|
| 2977 |
+
.shell-topbar {
|
| 2978 |
+
flex: 0 0 var(--lp-rail-head-h);
|
| 2979 |
+
height: var(--lp-rail-head-h);
|
| 2980 |
+
display: flex;
|
| 2981 |
+
align-items: center;
|
| 2982 |
+
gap: 10px;
|
| 2983 |
+
padding: 0 10px 0 12px;
|
| 2984 |
+
background: var(--lp-surface);
|
| 2985 |
+
border-bottom: 1px solid var(--lp-line);
|
| 2986 |
+
}
|
| 2987 |
+
/* β `min-height: 0` is load-bearing, not tidiness. A flex ITEM defaults to `min-height: auto`,
|
| 2988 |
+
which refuses to shrink below its content β so without this the rail's own scroll box and the
|
| 2989 |
+
grid host would size to their content and push the page past 100vh instead of scrolling inside
|
| 2990 |
+
it. The symptom is a page that scrolls as a whole and a grid with no internal scrollbar. */
|
| 2991 |
+
.shell-frame {
|
| 2992 |
+
flex: 1 1 auto;
|
| 2993 |
+
min-height: 0;
|
| 2994 |
+
display: flex;
|
| 2995 |
+
width: 100%;
|
| 2996 |
+
}
|
| 2997 |
+
.shell-topbar-toggle {
|
| 2998 |
+
flex: 0 0 28px;
|
| 2999 |
+
width: 28px;
|
| 3000 |
+
height: 28px;
|
| 3001 |
+
display: inline-flex;
|
| 3002 |
+
align-items: center;
|
| 3003 |
+
justify-content: center;
|
| 3004 |
+
border: 0;
|
| 3005 |
+
border-radius: var(--lp-r-md);
|
| 3006 |
+
background: transparent;
|
| 3007 |
+
color: var(--lp-muted);
|
| 3008 |
+
cursor: pointer;
|
| 3009 |
+
}
|
| 3010 |
+
.shell-topbar-toggle:hover { background: var(--lp-surface-2); color: var(--lp-ink); }
|
| 3011 |
+
.shell-topbar-toggle:focus-visible {
|
| 3012 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 3013 |
+
outline-offset: 1px;
|
| 3014 |
+
}
|
| 3015 |
+
.shell-topbar-brand {
|
| 3016 |
+
display: inline-flex;
|
| 3017 |
+
align-items: center;
|
| 3018 |
+
min-width: 0;
|
| 3019 |
+
}
|
| 3020 |
|
| 3021 |
/* WHITE, like the host's `[theme.sidebar]` (owner 2026-07-23: "slick, visible
|
| 3022 |
light-grey borders") β the wash belongs to page bodies, not the rail.
|
|
|
|
| 3036 |
flex-basis var(--lp-fold-t) var(--lp-fold-e);
|
| 3037 |
}
|
| 3038 |
|
| 3039 |
+
/* β WAVE 35 Β· T02 β WHAT THIS BAND IS NOW. It held the brand and the minimize control; both live
|
| 3040 |
+
in `.shell-topbar` (C1). It survives, empty, ONLY as an alignment band: `DbHead` and
|
| 3041 |
+
`.cg-views-top` are each one `--lp-rail-head-h` tall, so removing it would raise the rail's first
|
| 3042 |
+
nav row a header above the grid beside it on every database page. Same height, same hairline,
|
| 3043 |
+
no contents β `justify-content` and the fold `transition` went with the children they positioned. */
|
| 3044 |
.shell-side-head {
|
|
|
|
|
|
|
|
|
|
| 3045 |
flex: 0 0 var(--lp-rail-head-h);
|
| 3046 |
height: var(--lp-rail-head-h);
|
|
|
|
| 3047 |
border-bottom: 1px solid var(--lp-line);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3048 |
}
|
| 3049 |
+
/* ββ DELETED WITH T02, AND THIS NOTE IS THE POINT OF THE DELETION: `.shell-rail-toggle` (its base,
|
| 3050 |
+
hover and focus rules) and `.shell-side.is-collapsed .shell-rail-toggle { display: none }`.
|
| 3051 |
+
That last rule is what made wave-20 R9 necessary β the toggle vanished when collapsed, so the
|
| 3052 |
+
LOGO had to become the way back. Both the toggle and the logo now sit in the top strip, OUTSIDE
|
| 3053 |
+
`.shell-side`, so the collapsed selector could no longer reach them and the rule would have been
|
| 3054 |
+
an inert line that still reads as load-bearing to the next person. The way back is now simply
|
| 3055 |
+
that the toggle never leaves. Its styling lives on `.shell-topbar-toggle` above.
|
| 3056 |
+
β `.cg-rail-toggle` is a DIFFERENT control (the views/agents sub-rail) and is untouched. */
|
| 3057 |
.shell-side.is-collapsed {
|
| 3058 |
width: var(--lp-rail-min);
|
| 3059 |
flex-basis: var(--lp-rail-min);
|
| 3060 |
}
|
| 3061 |
+
/* Geometry note (item 1): nothing re-centers on collapse β paddings are tuned so the icons SLIDE
|
| 3062 |
+
to the strip's center as the width animates (a justify-content flip would snap them at t=0).
|
| 3063 |
+
Icon x: 6 + 10 = 16 = (48-16)/2. */
|
|
|
|
|
|
|
|
|
|
| 3064 |
.shell-side.is-collapsed .shell-brand { gap: 0; }
|
| 3065 |
.shell-side.is-collapsed .lp-wordmark,
|
| 3066 |
.shell-side.is-collapsed .shell-nav-label,
|
|
|
|
| 3080 |
gap: 0;
|
| 3081 |
}
|
| 3082 |
.shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 10px; }
|
| 3083 |
+
/* β WAVE 35 Β· T05 (owner item 6) β the Templates row sits in the rail's bottom band, above the
|
| 3084 |
+
account button, so it takes the band's own gutter rather than the nav list's. It is a
|
| 3085 |
+
`.shell-nav-item`, so weight, height, hover and the fold all come from the rail's shared rule
|
| 3086 |
+
and this adds only the placement. */
|
| 3087 |
+
.shell-nav-templates {
|
| 3088 |
+
margin: 0 8px 2px;
|
| 3089 |
+
width: calc(100% - 16px);
|
| 3090 |
+
}
|
| 3091 |
+
/* β W33-T75's law reaches this row too. `.shell-nav.is-rail-loading > *` cannot select it β this
|
| 3092 |
+
button is a SIBLING of `.shell-nav`, not a child β so while `/nav` is in flight it would paint in
|
| 3093 |
+
ink under six skeleton bars. HIDDEN, not unmounted, for the same reason the nav rows are: the
|
| 3094 |
+
node stays in the tree so nothing hanging off it is torn down for the length of a fetch. */
|
| 3095 |
+
.shell-nav-templates.is-rail-hidden { display: none; }
|
| 3096 |
+
.shell-side.is-collapsed .shell-nav-templates {
|
| 3097 |
+
margin: 0 6px 2px;
|
| 3098 |
+
width: calc(100% - 12px);
|
| 3099 |
+
padding: 7px 10px;
|
| 3100 |
+
}
|
| 3101 |
.shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
|
| 3102 |
.shell-side.is-collapsed .shell-account {
|
| 3103 |
padding: 7px 8px;
|
| 3104 |
gap: 0;
|
| 3105 |
}
|
| 3106 |
|
| 3107 |
+
/* β WAVE 35 Β· T02 β `.shell-brand-btn` IS DELETED, and it is the wave-20 R9 decision being
|
| 3108 |
+
retired rather than a style being tidied. The brand was a `<button>` at all only so it could be
|
| 3109 |
+
the way back from a collapsed rail; it is a plain `<div class="shell-topbar-brand">` in the top
|
| 3110 |
+
strip now, and the toggle beside it is the way back in both states. Keeping the button rules
|
| 3111 |
+
would leave the app describing the logo as a control that no longer does anything. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3112 |
.shell-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
| 3113 |
/* β DELIBERATELY OFF THE TYPE SCALE. The wordmark is BRAND, not UI text: 17px is what the app's
|
| 3114 |
own nav wordmark resolves to (1.02rem against its 17px base), and matching the sibling door
|
|
|
|
| 3217 |
/* β NOT in the shared rule above: `.shell-nav-group` is a LABEL, not a control
|
| 3218 |
("quieter, no hover, no cursor" β its own rule below), and a pointer cursor
|
| 3219 |
on a row that cannot be clicked promises a destination that does not exist. */
|
| 3220 |
+
/* `position: relative` is for W35-T03's left marker (`.is-active::before`). β It is safe on EVERY
|
| 3221 |
+
row, checked rather than assumed: the only absolutely-positioned descendant in this subtree is
|
| 3222 |
+
`.shell-side.is-collapsed .shell-nav-badge`, and its containing block is already
|
| 3223 |
+
`.shell-side.is-collapsed .shell-nav-alerts` β which IS a `.shell-nav-item`. So the badge keeps
|
| 3224 |
+
the same anchor and does not move; that older rule is now redundant rather than contradicted. */
|
| 3225 |
+
.shell-nav-item { cursor: pointer; position: relative; }
|
| 3226 |
.shell-nav-item:hover { background: var(--lp-surface-2); }
|
| 3227 |
.shell-nav-item.is-child { padding-left: 30px; }
|
| 3228 |
/* The current row is marked the way the host marks it: the selected-row tint,
|
|
|
|
| 3234 |
never had that, because there the ROW carries the tint. It does here now
|
| 3235 |
(`.shell-nav-row.is-active`, navExtras.css). What stays here is everything
|
| 3236 |
that genuinely belongs to the LINK: its weight, and the icon below. */
|
| 3237 |
+
/* ββ WAVE 35 Β· T03 (owner item 7) β "there should be a highlight so user know what module they
|
| 3238 |
+
are in". THE SCOUTED PREMISE WAS HALF TRUE AND THAT IS WHY THIS LOOKED LIKE A NO-OP: the blue
|
| 3239 |
+
tint lives on `.shell-nav-row.is-active` (navExtras.css), and a `.shell-nav-row` only wraps a
|
| 3240 |
+
DATABASE row. A bare MODULE row β Home, Inbox, Assistant, Agents, Database, Connectors, and now
|
| 3241 |
+
Starred β is an unwrapped `.shell-nav-item`, so all it ever got was `font-weight: 600` and a
|
| 3242 |
+
full-strength icon. Weight alone is the one signal that disappears at a glance, which is exactly
|
| 3243 |
+
the owner's complaint. The tint moves here so a module is marked the way a database already is,
|
| 3244 |
+
and a 3px left marker carries the distinction at the edge of vision.
|
| 3245 |
+
β THE `:hover` DUPLICATE IS LOAD-BEARING, NOT BELT-AND-BRACES. `.shell-nav-item:hover` and
|
| 3246 |
+
`.shell-nav-item.is-active` have the SAME specificity (0,2,0), so at rest the active row wins
|
| 3247 |
+
only because it is declared later β the source-order luck navExtras.css's own comment warns
|
| 3248 |
+
about, and this nav has lost a rule to source order before. `.is-active:hover` is (0,3,0) and
|
| 3249 |
+
settles it by specificity instead.
|
| 3250 |
+
β A DATABASE row is unaffected on purpose: its `.shell-nav-row` still paints the same tint
|
| 3251 |
+
underneath, so the two surfaces agree rather than double-painting a different colour. */
|
| 3252 |
+
.shell-nav-item.is-active,
|
| 3253 |
+
.shell-nav-item.is-active:hover {
|
| 3254 |
+
background: var(--lp-blue-tint);
|
| 3255 |
font-weight: 600;
|
| 3256 |
}
|
| 3257 |
+
/* β `--lp-primary`, NOT `--lp-blue-deep`, AND THE REASON IS MEASURED. Session B measured
|
| 3258 |
+
`--lp-blue-deep` (#768fb6) on `--lp-blue-tint` (#edf3fd) at **2.96:1**, under WCAG's 3:1 bar for
|
| 3259 |
+
a graphical object; I reproduced it and checked the neighbours, because the obvious substitute is
|
| 3260 |
+
not one: `--lp-blue-solid` is **2.95:1**, no better. `--lp-primary` (#5433cc) is **6.92:1** on the
|
| 3261 |
+
same tint. β Nothing in the battery measures a contrast ratio β `verify_ui` checks that font
|
| 3262 |
+
sizes are TOKENS, not that anything is legible β so this class of defect is found by measuring or
|
| 3263 |
+
not at all, and this note is the measurement.
|
| 3264 |
+
β DESIGN.md 3's "blue = selected" is untouched: the ROW TINT is still blue and still carries the
|
| 3265 |
+
meaning. This is the brand accent marking the edge, which is what needs to survive peripheral
|
| 3266 |
+
vision. */
|
| 3267 |
+
.shell-nav-item.is-active::before {
|
| 3268 |
+
content: "";
|
| 3269 |
+
position: absolute;
|
| 3270 |
+
left: 0;
|
| 3271 |
+
top: 50%;
|
| 3272 |
+
transform: translateY(-50%);
|
| 3273 |
+
width: 3px;
|
| 3274 |
+
height: 18px;
|
| 3275 |
+
border-radius: 0 var(--lp-r-sm) var(--lp-r-sm) 0;
|
| 3276 |
+
background: var(--lp-primary);
|
| 3277 |
+
}
|
| 3278 |
.shell-nav-item:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: -2px; }
|
| 3279 |
|
| 3280 |
.shell-nav-icon {
|
|
|
|
| 7438 |
It had the height and nothing else: the fold control sat LEFT while the Views rail's sits
|
| 7439 |
right, and there was no hairline at all β so the automation page's top band simply stopped
|
| 7440 |
halfway across the screen while every database page's ran edge to edge. The three things
|
| 7441 |
+
`.cg-views-top` does and this did not: a shared `justify-content`, the closing hairline,
|
| 7442 |
and the negative margin that bleeds that line past the rail's own gutter to its true edges
|
| 7443 |
(`overflow: hidden` on the rail clips the collapsed overshoot, exactly as it does there).
|
| 7444 |
β THE BLEED IS THIS RAIL'S OWN GUTTER, not a copied number: `.cg-views` pads 8px and bleeds
|
| 7445 |
-8px; this rail pads 10px, so it bleeds -10px. Copying the -8 would leave a 2px gap at each
|
| 7446 |
+
end β the same defect in a more convincing disguise.
|
| 7447 |
+
β WAVE 35 Β· T11 (owner item 1, R1) β and the shared value is `flex-start` now. This rail holds
|
| 7448 |
+
ONLY the toggle, so the flip is the whole change here; no TSX moves. */
|
| 7449 |
.auto-rail-top {
|
| 7450 |
flex: 0 0 var(--lp-rail-head-h);
|
| 7451 |
height: var(--lp-rail-head-h);
|
| 7452 |
display: flex;
|
| 7453 |
align-items: center;
|
| 7454 |
+
justify-content: flex-start;
|
| 7455 |
margin: 0 -10px;
|
| 7456 |
padding: 0 10px;
|
| 7457 |
border-bottom: 1px solid var(--lp-line);
|
|
|
|
| 10755 |
/* ββ Item 7 (R9) β the collapsed rail expands from its own background βββββββ
|
| 10756 |
The strip's blank area is now a click target, so it says so. Interactive
|
| 10757 |
children keep their own cursor (the UA default for a link/button is already
|
| 10758 |
+
`pointer`).
|
| 10759 |
+
β WAVE 35 Β· T02 corrected this note: it used to end "and `.shell-brand-btn` keeps the explicit
|
| 10760 |
+
one it had", naming a rule that no longer exists β the brand left the rail for the top strip and
|
| 10761 |
+
its button rules were deleted with it. This background click is now a CONVENIENCE rather than
|
| 10762 |
+
the accessible route back, because the toggle in the strip is present in both states. */
|
| 10763 |
.shell-side.is-collapsed { cursor: pointer; }
|
| 10764 |
|
| 10765 |
/* ==== /wave20:S4 ==== */
|
|
|
|
| 11085 |
/* ββ C10 β HOME βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11086 |
`reference/Airtable Home.png`'s anatomy, our tokens. `.shell-main` is `overflow: hidden`, so
|
| 11087 |
the page owns its own scroll or a tenant with forty recents can reach none of them. */
|
| 11088 |
+
/* ββ WAVE 35 Β· T10 (owner item 16) β "when I toggle between List and Gallery view under Home, the
|
| 11089 |
+
web width itself shift". THE CAUSE IS THIS SCROLLBAR, and it is fixed here rather than by padding
|
| 11090 |
+
the tiles, because padding hides a shift and leaves it.
|
| 11091 |
+
MEASURED on the live build, which is what identifies the container: in GALLERY the content fits
|
| 11092 |
+
and nothing here scrolls (the walk up from `.home-tiles` reaches `html`); in LIST the tiles
|
| 11093 |
+
stack one per row, `.shell-home` overflows and becomes the scroller. So a classic scrollbar
|
| 11094 |
+
appears in exactly one of the two layouts and takes its width out of the content box.
|
| 11095 |
+
`scrollbar-gutter: stable` reserves that space in BOTH, so the column is the same width either
|
| 11096 |
+
way and nothing moves.
|
| 11097 |
+
β THE PIXEL DELTA DOES NOT REPRODUCE IN HEADLESS CHROMIUM and that is a property of the
|
| 11098 |
+
instrument, not evidence against the diagnosis: headless paints OVERLAY scrollbars, which take
|
| 11099 |
+
no layout width, so the measured delta there is 0 while the owner sees ~15px on Windows Chrome.
|
| 11100 |
+
What the headless run DOES prove is the mechanism β the scroller identity flips between the two
|
| 11101 |
+
layouts. */
|
| 11102 |
.shell-home {
|
| 11103 |
height: 100%;
|
| 11104 |
overflow-y: auto;
|
| 11105 |
+
scrollbar-gutter: stable;
|
| 11106 |
padding: 34px 44px 56px;
|
| 11107 |
box-sizing: border-box;
|
| 11108 |
background: var(--lp-wash);
|
|
|
|
| 11176 |
border-radius: var(--lp-r-md);
|
| 11177 |
background: var(--lp-surface);
|
| 11178 |
}
|
| 11179 |
+
/* β WAVE 35 Β· T10 (owner item 10) β "that button toggle to be larger". MEASURED at 26x22 before
|
| 11180 |
+
this, which is under every common 32px minimum-target guideline in BOTH axes; it is now 36x32,
|
| 11181 |
+
so the smaller side clears 32. The icon grows with the box (14 -> 16px) or a larger button just
|
| 11182 |
+
frames the same small mark in more empty space, which reads as a bigger gap rather than a bigger
|
| 11183 |
+
control. */
|
| 11184 |
.home-layout-btn {
|
| 11185 |
display: inline-flex;
|
| 11186 |
align-items: center;
|
| 11187 |
justify-content: center;
|
| 11188 |
+
width: 36px;
|
| 11189 |
+
height: 32px;
|
| 11190 |
border: 0;
|
| 11191 |
border-radius: var(--lp-r-sm);
|
| 11192 |
background: transparent;
|
|
|
|
| 11196 |
cursor: pointer;
|
| 11197 |
}
|
| 11198 |
.home-layout-btn:hover { background: var(--lp-surface-2); }
|
| 11199 |
+
/* Blue = selected/active, the fixed semantic (DESIGN.md Β§3) β the TINT still carries that meaning.
|
| 11200 |
+
β THE INK MOVED OFF `--lp-blue-deep`, AND IT IS A MEASUREMENT, NOT A PREFERENCE. Session B
|
| 11201 |
+
measured this exact pair off a real render at **2.96:1** β under the 4.5:1 text bar and under the
|
| 11202 |
+
3:1 graphical bar β and raised it because this button is where they copied it FROM. β The obvious
|
| 11203 |
+
substitute is not one: `--lp-blue-solid` is **2.95:1**. `--lp-primary` is **6.92:1** on the same
|
| 11204 |
+
tint. This button's whole content is a 16px icon, so its contrast IS its legibility.
|
| 11205 |
+
β Nothing in the battery measures a contrast ratio β `verify_ui` checks that sizes are TOKENS,
|
| 11206 |
+
not that anything is readable β so this class of defect is found by measuring or not at all. */
|
| 11207 |
+
.home-layout-btn.is-on { background: var(--lp-blue-tint); color: var(--lp-primary); }
|
| 11208 |
.home-layout-icon {
|
| 11209 |
+
width: 16px;
|
| 11210 |
+
height: 16px;
|
| 11211 |
fill: none;
|
| 11212 |
stroke: currentColor;
|
| 11213 |
stroke-width: 1.35;
|
|
|
|
| 12377 |
text-align: center;
|
| 12378 |
line-height: 1;
|
| 12379 |
}
|
| 12380 |
+
/* ββ WAVE 35 Β· T11 (owner item 9, R4) β THE DATABASE-LEVEL TOTAL IS GONE, AND SO IS EVERY RULE
|
| 12381 |
+
THAT DRESSED IT. Owner item 9: the count belongs to the VIEW itself and nowhere else. Wave 33's
|
| 12382 |
+
T16 put a per-database total at the top of this rail and wave 34's T20 stripped its label; both
|
| 12383 |
+
notes are now history and live in `.claude/wiki/waves/wave35/` rather than here.
|
| 12384 |
+
β FOUR RULES DIED TOGETHER ON PURPOSE, and this note is what stops one of them coming back
|
| 12385 |
+
alone: `.cg-views-important` (the wrapper's `margin-left: auto`, which outranked
|
| 12386 |
+
`.cg-views-top`'s `justify-content` and is why the fold toggle sat left on a database with a
|
| 12387 |
+
marked view and right on one without), the two `.cg-views-important .cg-view-count` size rules,
|
| 12388 |
+
and the `.is-partial` dashed mark. The ELEMENT they styled leaves in W35-T27 (`ViewSidebar.tsx`,
|
| 12389 |
+
session C).
|
| 12390 |
+
β WHAT SURVIVES, AND IT IS NOT THE SAME THING: `.cg-view-count` and `.cg-view-count.is-unknown`
|
| 12391 |
+
below are the PER-VIEW badges on each view row. Owner item 9 keeps those. */
|
| 12392 |
/* β WAVE-33 T14 (D-205) β the same mark, saying "no number here, and here is why" (its `title`).
|
| 12393 |
β NOT the solid purple: a filled badge is the product's word for "this many", and wearing it
|
| 12394 |
over a dash would state a count of nothing. Outlined and muted says "the slot exists, the
|
| 12395 |
number does not" β which is exactly the distinction an ABSENT badge could not draw, and the
|
| 12396 |
whole of D-205. Same box, so a rail of marked views does not jump when one of them cannot be
|
| 12397 |
counted. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12398 |
.cg-view-count.is-unknown {
|
| 12399 |
background: transparent;
|
| 12400 |
color: var(--lp-muted);
|
web/src/query/QueryPage.tsx
CHANGED
|
@@ -4,13 +4,12 @@ import type { QueryOpenDetail } from "../apiContract";
|
|
| 4 |
import { QUERY_OPEN_EVENT } from "../apiContract";
|
| 5 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 6 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 7 |
-
import { queryCitationLabel } from "../customer-grid/queryPreview";
|
| 8 |
import { DbHead, gridScopeFor } from "../shell/dbFrame";
|
| 9 |
import { databaseEntries } from "../shell/nav";
|
| 10 |
import type { NavEntry } from "../shell/nav";
|
| 11 |
import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
|
| 12 |
import type { QueryCitation, SavedQuery } from "./queryApi";
|
| 13 |
-
import { QueryEmpty, queryGroups,
|
| 14 |
import type { QueryGroup } from "./queryParts";
|
| 15 |
import "./query.css";
|
| 16 |
|
|
@@ -134,9 +133,6 @@ export default function QueryPage({ granted, hostedRail, refreshToken, selectedI
|
|
| 134 |
|
| 135 |
if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
|
| 136 |
const entry = entryOf(active.source.database);
|
| 137 |
-
const cited = citations.filter((citation) => active.citationIds.includes(citation.id));
|
| 138 |
-
const provenance = cited.map(queryCitationLabel).join(" Β· ")
|
| 139 |
-
|| `${active.source.label} Β· snapshot ${String(active.source.source_version)} Β· retrieved ${active.source.retrieved_at}`;
|
| 140 |
return (
|
| 141 |
<div className="shell-db-frame">
|
| 142 |
<DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
|
|
@@ -146,8 +142,15 @@ export default function QueryPage({ granted, hostedRail, refreshToken, selectedI
|
|
| 146 |
<QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
|
| 147 |
onDelete={(id) => void remove(id)} onChanged={upsert} />
|
| 148 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
<div className="qy-surface">
|
| 150 |
-
<QueryProvenance view={active} detail={provenance} />
|
| 151 |
<div className="qy-grid-host">
|
| 152 |
<OverlayProvider>
|
| 153 |
<CustomerGrid key={gridScopeFor(active.source.database)}
|
|
|
|
| 4 |
import { QUERY_OPEN_EVENT } from "../apiContract";
|
| 5 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 6 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
|
|
|
| 7 |
import { DbHead, gridScopeFor } from "../shell/dbFrame";
|
| 8 |
import { databaseEntries } from "../shell/nav";
|
| 9 |
import type { NavEntry } from "../shell/nav";
|
| 10 |
import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
|
| 11 |
import type { QueryCitation, SavedQuery } from "./queryApi";
|
| 12 |
+
import { QueryEmpty, queryGroups, QueryRail } from "./queryParts";
|
| 13 |
import type { QueryGroup } from "./queryParts";
|
| 14 |
import "./query.css";
|
| 15 |
|
|
|
|
| 133 |
|
| 134 |
if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
|
| 135 |
const entry = entryOf(active.source.database);
|
|
|
|
|
|
|
|
|
|
| 136 |
return (
|
| 137 |
<div className="shell-db-frame">
|
| 138 |
<DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
|
|
|
|
| 142 |
<QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
|
| 143 |
onDelete={(id) => void remove(id)} onChanged={upsert} />
|
| 144 |
)}
|
| 145 |
+
{/* β NO PROVENANCE ROW HERE (W35-T21, owner item 4 / R2). The "Sources" disclosure and
|
| 146 |
+
the snapshot line it opened are deleted; a Query view is a database view now, and a
|
| 147 |
+
database view carries no banner. The citations still travel β `binding` above is
|
| 148 |
+
built from this artefact AND the fetched citation list β and the reader still SEES
|
| 149 |
+
them in the chat, under the answer that used them (`AssistantPage::CitationLine`).
|
| 150 |
+
β Do NOT paste that builder call into a comment: `verify_grid_ux.py`'s NC mutates the
|
| 151 |
+
FIRST occurrence of it, so a second copy in prose keeps the scan green over a page
|
| 152 |
+
that stopped binding citations [[prose-that-becomes-its-own-marker]]. */}
|
| 153 |
<div className="qy-surface">
|
|
|
|
| 154 |
<div className="qy-grid-host">
|
| 155 |
<OverlayProvider>
|
| 156 |
<CustomerGrid key={gridScopeFor(active.source.database)}
|
web/src/query/query.css
CHANGED
|
@@ -250,10 +250,53 @@
|
|
| 250 |
.qy-rail-row.is-active .qy-rail-name { font-weight: 600; }
|
| 251 |
|
| 252 |
.qy-rail-kind {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
color: var(--lp-muted);
|
| 254 |
font-size: var(--lp-fs-2xs);
|
| 255 |
}
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
/* Hover-revealed, like the grid rail's own row menu β visible on focus so it is
|
| 258 |
reachable from the keyboard rather than only from a pointer. */
|
| 259 |
.qy-rail-del {
|
|
@@ -311,7 +354,8 @@
|
|
| 311 |
|
| 312 |
.qy-rail-ask:hover { background: var(--lp-surface-2); }
|
| 313 |
|
| 314 |
-
/* ββ the surface:
|
|
|
|
| 315 |
|
| 316 |
.qy-surface {
|
| 317 |
flex: 1 1 auto;
|
|
@@ -328,50 +372,6 @@
|
|
| 328 |
overflow: hidden;
|
| 329 |
}
|
| 330 |
|
| 331 |
-
.qy-prov {
|
| 332 |
-
flex: 0 0 auto;
|
| 333 |
-
border-bottom: 1px solid var(--lp-line);
|
| 334 |
-
background: var(--lp-surface);
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
.qy-prov-btn {
|
| 338 |
-
display: flex;
|
| 339 |
-
align-items: baseline;
|
| 340 |
-
gap: 10px;
|
| 341 |
-
width: 100%;
|
| 342 |
-
padding: 7px 14px;
|
| 343 |
-
border: 0;
|
| 344 |
-
background: transparent;
|
| 345 |
-
font: inherit;
|
| 346 |
-
text-align: left;
|
| 347 |
-
cursor: pointer;
|
| 348 |
-
}
|
| 349 |
-
|
| 350 |
-
.qy-prov-q {
|
| 351 |
-
flex: 1 1 auto;
|
| 352 |
-
min-width: 0;
|
| 353 |
-
overflow: hidden;
|
| 354 |
-
text-overflow: ellipsis;
|
| 355 |
-
white-space: nowrap;
|
| 356 |
-
color: var(--lp-muted);
|
| 357 |
-
font-size: var(--lp-fs-xs);
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
.qy-prov-toggle {
|
| 361 |
-
flex: 0 0 auto;
|
| 362 |
-
color: var(--lp-blue-solid);
|
| 363 |
-
font-size: var(--lp-fs-2xs);
|
| 364 |
-
font-weight: 600;
|
| 365 |
-
}
|
| 366 |
-
|
| 367 |
-
.qy-prov-detail {
|
| 368 |
-
margin: 0;
|
| 369 |
-
padding: 0 14px 9px;
|
| 370 |
-
color: var(--lp-muted);
|
| 371 |
-
font-size: var(--lp-fs-2xs);
|
| 372 |
-
line-height: var(--lp-lh);
|
| 373 |
-
word-break: break-word;
|
| 374 |
-
}
|
| 375 |
|
| 376 |
/* ββ the empty state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 377 |
|
|
|
|
| 250 |
.qy-rail-row.is-active .qy-rail-name { font-weight: 600; }
|
| 251 |
|
| 252 |
.qy-rail-kind {
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
gap: 6px;
|
| 256 |
+
min-width: 0;
|
| 257 |
color: var(--lp-muted);
|
| 258 |
font-size: var(--lp-fs-2xs);
|
| 259 |
}
|
| 260 |
|
| 261 |
+
/* β W35-T25 (R3) β the Edited mark. Quiet on purpose: it reports a state the reader created
|
| 262 |
+
themselves, so it belongs beside the kind rather than competing with the view's name. */
|
| 263 |
+
.qy-rail-edited {
|
| 264 |
+
flex: 0 0 auto;
|
| 265 |
+
padding: 0 5px;
|
| 266 |
+
border-radius: var(--lp-r-sm);
|
| 267 |
+
background: var(--lp-surface-2);
|
| 268 |
+
color: var(--lp-muted);
|
| 269 |
+
font-size: var(--lp-fs-2xs);
|
| 270 |
+
font-weight: 600;
|
| 271 |
+
line-height: 15px;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.qy-rail-row.is-active .qy-rail-edited { background: var(--lp-surface); }
|
| 275 |
+
|
| 276 |
+
/* ββ ββ W35-T47 (R4, C2) β the SHARED star button, re-placed for a rail row βββββββββββββββββ
|
| 277 |
+
*
|
| 278 |
+
* β GEOMETRY ONLY. `StarButton` is B's component and `starred.css` owns everything about how it
|
| 279 |
+
* looks; overriding its colour or its glyph here would be a second opinion about one mark. What a
|
| 280 |
+
* rail row genuinely needs different is placement: the shared rule is `position: absolute` on a
|
| 281 |
+
* TILE wrapper, and this row is a flex list whose right end already holds the actions button, so
|
| 282 |
+
* an absolutely positioned star would sit on top of it.
|
| 283 |
+
*
|
| 284 |
+
* β AND THE REVEAL RULE HAS TO BE RESTATED, not inherited. The shared sheet reveals the star with
|
| 285 |
+
* `.st-tilewrap:hover`, a wrapper that does not exist here β so without the row's own hover rule
|
| 286 |
+
* the button would be `opacity: 0` forever except when starred or focused, i.e. a control you can
|
| 287 |
+
* only find by tabbing to it. `.is-on` and `:focus-visible` keep working from the shared sheet.
|
| 288 |
+
*/
|
| 289 |
+
.qy-rail-row .st-star {
|
| 290 |
+
position: static;
|
| 291 |
+
transform: none;
|
| 292 |
+
flex: 0 0 auto;
|
| 293 |
+
width: 22px;
|
| 294 |
+
height: 22px;
|
| 295 |
+
margin-right: 2px;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.qy-rail-row:hover .st-star { opacity: 1; }
|
| 299 |
+
|
| 300 |
/* Hover-revealed, like the grid rail's own row menu β visible on focus so it is
|
| 301 |
reachable from the keyboard rather than only from a pointer. */
|
| 302 |
.qy-rail-del {
|
|
|
|
| 354 |
|
| 355 |
.qy-rail-ask:hover { background: var(--lp-surface-2); }
|
| 356 |
|
| 357 |
+
/* ββ the surface: the grid, and nothing above it ββββββββββββββββββββββββββββ */
|
| 358 |
+
/* W35-T21 (owner item 4 / R2): the `.qy-prov*` family is deleted with the component. */
|
| 359 |
|
| 360 |
.qy-surface {
|
| 361 |
flex: 1 1 auto;
|
|
|
|
| 372 |
overflow: hidden;
|
| 373 |
}
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
/* ββ the empty state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 377 |
|
web/src/query/queryApi.ts
CHANGED
|
@@ -47,6 +47,21 @@ export interface SavedQuery {
|
|
| 47 |
view: Record<string, unknown>;
|
| 48 |
citationIds: string[];
|
| 49 |
numeric: { label: string; value: number | null; contributing_record_count: number };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
export interface QueryThread {
|
|
@@ -133,13 +148,32 @@ export interface QueryVirtualBinding {
|
|
| 133 |
source: QuerySource;
|
| 134 |
view: Record<string, unknown>;
|
| 135 |
citationIds: string[];
|
| 136 |
-
/** Complete, clickable provenance for this exact
|
| 137 |
citations: QueryCitation[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
/**
|
| 141 |
-
*
|
| 142 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
*/
|
| 144 |
export type QueryMutationOperation = "create" | "update" | "delete";
|
| 145 |
export interface QueryMutationRefusal {
|
|
@@ -169,9 +203,18 @@ const IMMUTABLE_MUTATION: QueryMutationRefusal = {
|
|
| 169 |
durable: false,
|
| 170 |
transport: "none",
|
| 171 |
code: "query_view_immutable",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
// "the Assistant" β the rail's own word since W34-T13. Copy that names a module must name it
|
| 173 |
// the way the reader sees it, or the sentence sends them looking for a door that is not there.
|
| 174 |
-
message: "
|
|
|
|
| 175 |
};
|
| 176 |
export const QUERY_MUTATION_POLICY: QueryMutationPolicy = {
|
| 177 |
create: IMMUTABLE_MUTATION,
|
|
@@ -283,6 +326,12 @@ function saved(value: unknown): SavedQuery | null {
|
|
| 283 |
view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
|
| 284 |
citationIds: (row.citationIds as unknown[] || []).filter((id): id is string => typeof id === "string"),
|
| 285 |
numeric: ((row.numeric && typeof row.numeric === "object" ? row.numeric : {}) as SavedQuery["numeric"]),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
};
|
| 287 |
}
|
| 288 |
|
|
@@ -392,6 +441,18 @@ export function duplicateQuery(id: string): Promise<Result<SavedQuery>> {
|
|
| 392 |
return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
|
| 393 |
}
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
/**
|
| 396 |
* A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
|
| 397 |
* names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
|
|
|
|
| 47 |
view: Record<string, unknown>;
|
| 48 |
citationIds: string[];
|
| 49 |
numeric: { label: string; value: number | null; contributing_record_count: number };
|
| 50 |
+
/**
|
| 51 |
+
* ββ W35-T25 Β· CONTRACT C4 (R3) β THE AI'S OWN SPEC, AND WHETHER THIS ONE STILL MATCHES IT.
|
| 52 |
+
*
|
| 53 |
+
* `original_spec` is written ONCE, at creation, and is what "Revert to AI original" restores.
|
| 54 |
+
* `edited` is DERIVED on the server by comparing the two, never stored β so a revert clears the
|
| 55 |
+
* badge by construction rather than by remembering to.
|
| 56 |
+
*
|
| 57 |
+
* β `null` IS A REAL ANSWER AND THE CLIENT MUST HONOUR IT. An artefact created before this wave
|
| 58 |
+
* has no original: it reports `edited: false` and offers no Revert. C4 is explicit that a Revert
|
| 59 |
+
* with nothing to revert to is worse than an absent one.
|
| 60 |
+
* β The snake_case key is contract C4's own spelling, not a slip. It is the wire name both
|
| 61 |
+
* halves were specified against and renaming it here would be a silent one-sided change.
|
| 62 |
+
*/
|
| 63 |
+
original_spec: Record<string, unknown> | null;
|
| 64 |
+
edited: boolean;
|
| 65 |
}
|
| 66 |
|
| 67 |
export interface QueryThread {
|
|
|
|
| 148 |
source: QuerySource;
|
| 149 |
view: Record<string, unknown>;
|
| 150 |
citationIds: string[];
|
| 151 |
+
/** Complete, clickable provenance for this exact virtual artefact. */
|
| 152 |
citations: QueryCitation[];
|
| 153 |
+
/*
|
| 154 |
+
* β NO `edited` HERE, AND THE ABSENCE IS THE DESIGN (W35-T23 + T25).
|
| 155 |
+
*
|
| 156 |
+
* A draft of this carried one, because the grid kept the reader's spec in a local bucket and
|
| 157 |
+
* needed a flag to decide whether that copy or the server's was current. That flag went stale
|
| 158 |
+
* inside a session β `binding` is memoised on the artefact and nothing refetches it after an
|
| 159 |
+
* autosave β so the first edit of a session survived a full reload and vanished on an in-session
|
| 160 |
+
* remount. The fix was to delete the second store, not to freshen the flag: `view` above IS the
|
| 161 |
+
* artefact's spec, and it is the only copy. The `edited` a reader SEES lives on `SavedQuery`,
|
| 162 |
+
* where the rail reads it, and the renderer has no business asking.
|
| 163 |
+
*/
|
| 164 |
}
|
| 165 |
|
| 166 |
/**
|
| 167 |
+
* The Query workspace's mutation policy.
|
| 168 |
+
*
|
| 169 |
+
* ββ W35-T23 (owner item 4 / R2) β `update` NO LONGER MEANS "REFUSED", and this block is the
|
| 170 |
+
* contract, so the change is stated here rather than only where it is enforced. A Query view is a
|
| 171 |
+
* live view of its source database: resize, sort, group, hide and row height all write its spec,
|
| 172 |
+
* through `POST /query/{qid}/events` and nowhere else. `create` is still refused β an artefact
|
| 173 |
+
* holds exactly one view β and `delete` still removes the artefact itself.
|
| 174 |
+
* β `QUERY_MUTATION_POLICY.update` is kept in the shape because the wire contract has three
|
| 175 |
+
* readers and dropping a member is a breaking change; what it describes is now the refusal a
|
| 176 |
+
* mismatched id gets, not every edit.
|
| 177 |
*/
|
| 178 |
export type QueryMutationOperation = "create" | "update" | "delete";
|
| 179 |
export interface QueryMutationRefusal {
|
|
|
|
| 203 |
durable: false,
|
| 204 |
transport: "none",
|
| 205 |
code: "query_view_immutable",
|
| 206 |
+
// ββ W35-T23 (R2) β THE SENTENCE MOVED WITH THE RULE. It read *"This Query view is immutable.
|
| 207 |
+
// Ask the Assistant to create a revised view."* and that is no longer true: a Query view's
|
| 208 |
+
// spec is editable now, and the reader who resized a column would have been told to go and ask
|
| 209 |
+
// a chatbot. What this refusal still covers is the ONE thing R2 did not open β a SECOND view
|
| 210 |
+
// inside an artefact that holds exactly one.
|
| 211 |
+
// β The CODE is unchanged (`query_view_immutable`): it is the wire contract three gates and
|
| 212 |
+
// the server's own refusal key on, and the reason for the refusal did not change, only its
|
| 213 |
+
// scope. Renaming it would be a protocol change dressed as a copy edit.
|
| 214 |
// "the Assistant" β the rail's own word since W34-T13. Copy that names a module must name it
|
| 215 |
// the way the reader sees it, or the sentence sends them looking for a door that is not there.
|
| 216 |
+
message: "A Query view holds one saved answer, so it cannot take a second view. "
|
| 217 |
+
+ "Ask the Assistant for another one.",
|
| 218 |
};
|
| 219 |
export const QUERY_MUTATION_POLICY: QueryMutationPolicy = {
|
| 220 |
create: IMMUTABLE_MUTATION,
|
|
|
|
| 326 |
view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
|
| 327 |
citationIds: (row.citationIds as unknown[] || []).filter((id): id is string => typeof id === "string"),
|
| 328 |
numeric: ((row.numeric && typeof row.numeric === "object" ? row.numeric : {}) as SavedQuery["numeric"]),
|
| 329 |
+
// β ABSENT β NOT EDITED AND NOT REVERTABLE, which is the safe direction: an older server that
|
| 330 |
+
// sends neither key leaves the badge off and the control hidden, rather than offering a Revert
|
| 331 |
+
// whose endpoint will 409.
|
| 332 |
+
original_spec: (row.original_spec && typeof row.original_spec === "object"
|
| 333 |
+
? row.original_spec : null) as Record<string, unknown> | null,
|
| 334 |
+
edited: row.edited === true,
|
| 335 |
};
|
| 336 |
}
|
| 337 |
|
|
|
|
| 441 |
return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
|
| 442 |
}
|
| 443 |
|
| 444 |
+
/**
|
| 445 |
+
* ββ W35-T25 Β· CONTRACT C4 (R3) β "Revert to AI original".
|
| 446 |
+
*
|
| 447 |
+
* β IT RESTORES THE VIEW SPEC AND NOTHING ELSE, and the caller must SAY so before it acts. A
|
| 448 |
+
* Query view is live now, so a cell the reader changed through it is a real write to a real
|
| 449 |
+
* record in the source database; putting the filters back cannot walk that back. R3 requires the
|
| 450 |
+
* confirm to state that BEFORE the click, not after.
|
| 451 |
+
*/
|
| 452 |
+
export function revertQuery(id: string): Promise<Result<SavedQuery>> {
|
| 453 |
+
return call(`/query/${encodeURIComponent(id)}/revert`, json({}), (raw) => saved(raw) as SavedQuery);
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
/**
|
| 457 |
* A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
|
| 458 |
* names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
|
web/src/query/queryParts.tsx
CHANGED
|
@@ -1,23 +1,63 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
|
| 3 |
import { FolderMark } from "../customer-grid/icons";
|
| 4 |
import { DbIcon } from "../shell/dbFrame";
|
| 5 |
import { ASSISTANT_ROUTE } from "../shell/nav";
|
| 6 |
import type { NavEntry } from "../shell/nav";
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import type { SavedQuery } from "./queryApi";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
/** Which label of which row is being typed into. One at a time, by construction. */
|
| 11 |
interface Editing { id: string; field: "name" | "description"; value: string; }
|
| 12 |
|
| 13 |
/**
|
| 14 |
-
*
|
| 15 |
* `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
|
| 16 |
* on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
|
| 17 |
* nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
|
| 18 |
* Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
|
| 19 |
*
|
| 20 |
-
*
|
| 21 |
* does not size anything, so being a little wrong shifts the menu rather than breaking it.
|
| 22 |
*/
|
| 23 |
const MENU_H = 190;
|
|
@@ -51,7 +91,7 @@ export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?:
|
|
| 51 |
);
|
| 52 |
}
|
| 53 |
|
| 54 |
-
/** The kind glyph a Query row wears
|
| 55 |
const KIND_MARK: Record<string, string> = {
|
| 56 |
grid: "Table", list: "List", chart: "Chart", kanban: "Board",
|
| 57 |
calendar: "Calendar", timeseries: "Time series", map: "Map",
|
|
@@ -65,17 +105,17 @@ export interface QueryGroup {
|
|
| 65 |
}
|
| 66 |
|
| 67 |
/**
|
| 68 |
-
*
|
| 69 |
* DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
|
| 70 |
-
* Customers must not move the Customers heading
|
| 71 |
* newest is first, which is the order the server already sends.
|
| 72 |
*
|
| 73 |
-
*
|
| 74 |
* than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
|
| 75 |
* panel; two hosts computing "which views belong to which database" from two copies of one loop
|
| 76 |
* is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
|
| 77 |
* It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
|
| 78 |
-
*
|
| 79 |
* the lazy boundary the Assistant keeps around it.
|
| 80 |
*
|
| 81 |
* A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
|
|
@@ -102,21 +142,21 @@ export function queryGroups(views: SavedQuery[], entries: NavEntry[]): QueryGrou
|
|
| 102 |
}
|
| 103 |
|
| 104 |
/**
|
| 105 |
-
*
|
| 106 |
* Verbatim: *"these AI generated query should live under the query module. And that each View
|
| 107 |
* correspond to the relevant database. So we need to remove that dumb dropdown that suddenly
|
| 108 |
* appears at the top right of the Database when the AI does query. That's not how we should do
|
| 109 |
* the navigation."*
|
| 110 |
*
|
| 111 |
-
*
|
| 112 |
* previous pass did) left Query with no way to move between artefacts at all: the page picked
|
| 113 |
* `known[0]` and every other view the assistant had ever built was unreachable. A surface with
|
| 114 |
* one implicit selection is not "no dropdown", it is no navigation, and the owner asked for the
|
| 115 |
* navigation to be done differently rather than removed.
|
| 116 |
*
|
| 117 |
-
*
|
| 118 |
-
* sentence
|
| 119 |
-
* with all the views etc."*
|
| 120 |
* occupies on every other database, at the same `--lp-rail-w`. It is a separate component only
|
| 121 |
* because THIS list spans several databases and the grid's rail, by construction, cannot: the
|
| 122 |
* grid is mounted at one scope.
|
|
@@ -127,7 +167,7 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 127 |
onSelect: (id: string) => void;
|
| 128 |
onDelete: (id: string) => void;
|
| 129 |
/**
|
| 130 |
-
*
|
| 131 |
* upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
|
| 132 |
* putting the calls in the hosts would be one question with two implementations, and the copy
|
| 133 |
* that drifts is the one nobody is looking at.
|
|
@@ -140,10 +180,19 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 140 |
const [menuFor, setMenuFor] = useState("");
|
| 141 |
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
|
| 142 |
const [editing, setEditing] = useState<Editing | null>(null);
|
| 143 |
-
//
|
| 144 |
// differently; sharing one slot painted "Saved as ..." in the refusal red.
|
| 145 |
const [problem, setProblem] = useState("");
|
| 146 |
const [notice, setNotice] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
const said = (message: string) => { setNotice(message); setProblem(""); };
|
| 148 |
const failed = (message: string) => { setProblem(message); setNotice(""); };
|
| 149 |
|
|
@@ -159,7 +208,7 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 159 |
};
|
| 160 |
|
| 161 |
/**
|
| 162 |
-
*
|
| 163 |
* one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
|
| 164 |
* the sum. Saying so beats a view that looks complete and answers a different question.
|
| 165 |
*/
|
|
@@ -171,9 +220,36 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 171 |
: `Saved as "${result.value.name}" in this database's own views.`);
|
| 172 |
};
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
const duplicate = async (id: string) => {
|
| 175 |
const result = await duplicateQuery(id);
|
| 176 |
-
//
|
| 177 |
// that silently did not happen is the failure mode this whole module is written against.
|
| 178 |
if (!result.ok) { failed(result.message); return; }
|
| 179 |
said("");
|
|
@@ -221,9 +297,41 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 221 |
title={view.description || view.question}
|
| 222 |
>
|
| 223 |
<span className="qy-rail-name">{view.name}</span>
|
| 224 |
-
<span className="qy-rail-kind">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
</button>
|
| 226 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
{confirming === view.id ? (
|
| 228 |
<button type="button" className="qy-rail-confirm"
|
| 229 |
onClick={() => { setConfirming(""); onDelete(view.id); }}>
|
|
@@ -266,6 +374,15 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 266 |
</button>
|
| 267 |
<button type="button" className="qy-rail-item" role="menuitem"
|
| 268 |
onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
{/* R20's one difference from a database view's menu: this one can BECOME one. */}
|
| 270 |
<button type="button" className="qy-rail-item" role="menuitem"
|
| 271 |
onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
|
|
@@ -288,21 +405,16 @@ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
|
| 288 |
);
|
| 289 |
}
|
| 290 |
|
| 291 |
-
/*
|
| 292 |
-
*
|
|
|
|
|
|
|
|
|
|
| 293 |
*
|
| 294 |
-
*
|
| 295 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
*/
|
| 297 |
-
export function QueryProvenance({ view, detail }: { view: SavedQuery; detail: string }) {
|
| 298 |
-
const [open, setOpen] = useState(false);
|
| 299 |
-
return (
|
| 300 |
-
<div className={"qy-prov" + (open ? " is-open" : "")}>
|
| 301 |
-
<button type="button" className="qy-prov-btn" aria-expanded={open} onClick={() => setOpen(!open)}>
|
| 302 |
-
<span className="qy-prov-q">{view.question}</span>
|
| 303 |
-
<span className="qy-prov-toggle">{open ? "Hide sources" : "Sources"}</span>
|
| 304 |
-
</button>
|
| 305 |
-
{open ? <p className="qy-prov-detail">{detail}</p> : null}
|
| 306 |
-
</div>
|
| 307 |
-
);
|
| 308 |
-
}
|
|
|
|
| 1 |
+
ο»Ώimport { useState } from "react";
|
| 2 |
|
| 3 |
import { FolderMark } from "../customer-grid/icons";
|
| 4 |
import { DbIcon } from "../shell/dbFrame";
|
| 5 |
import { ASSISTANT_ROUTE } from "../shell/nav";
|
| 6 |
import type { NavEntry } from "../shell/nav";
|
| 7 |
+
/**
|
| 8 |
+
* ββ W35-T47 (R4, contract C2) β ONE STAR, AND THE BEHAVIOUR IS BORROWED WHOLE.
|
| 9 |
+
*
|
| 10 |
+
* `useStarred` is B's: the read, the optimistic flip, the reconcile, the toast on refusal, the
|
| 11 |
+
* generation guard against two writes racing. R4 makes star and "mark important" ONE idea, so a
|
| 12 |
+
* second client for it here would be the drift that ruling exists to prevent.
|
| 13 |
+
*
|
| 14 |
+
* β THE BUTTON'S MARKUP IS INLINE AND THAT IS NOT LAZINESS β IMPORTING `StarButton` BREAKS A's
|
| 15 |
+
* GATE. `StarredPage.tsx` imports `shell/Brand`, which reads `import.meta.env.BASE_URL`, and
|
| 16 |
+
* `verify_wiring.py` COMPILES AND RUNS `query/_test/queryRender.test.tsx` under node as CommonJS:
|
| 17 |
+
* `import.meta` is ESM-only syntax, node flips the emitted module to ESM, and the run dies with
|
| 18 |
+
* `ReferenceError: exports is not defined in ES module scope`. Reproduced here first, which is the
|
| 19 |
+
* only reason it is not a red `web_wiring` in somebody else's lane.
|
| 20 |
+
* β SO WHAT IS COPIED IS EIGHT LINES OF MARKUP, using B's own `.st-star` classes and the shared
|
| 21 |
+
* `StarIcon`, and `query.css` restyles nothing except placement. Posted to B as `NOTE C-16`: if
|
| 22 |
+
* `StarButton` moves into `starredApi`, this becomes an import.
|
| 23 |
+
*/
|
| 24 |
+
import { StarIcon } from "../ui/icons";
|
| 25 |
+
import { useStarred } from "../starred/starredApi";
|
| 26 |
+
import { duplicateQuery, exportQueryHref, renameQuery, revertQuery, saveQueryToDatabase } from "./queryApi";
|
| 27 |
import type { SavedQuery } from "./queryApi";
|
| 28 |
+
/**
|
| 29 |
+
* Γ’ΒΒΓ’ΒΒ W35-T22 (owner item 3) Γ’β¬β THE STYLESHEET BELONGS TO THE MODULE THAT RENDERS THE CLASSES, AND
|
| 30 |
+
* THAT IS THE WHOLE FIX. Owner, verbatim: *"When I click 'Query' toggle, before it loads, it shows
|
| 31 |
+
* a really ugly outdated format, fix it."*
|
| 32 |
+
*
|
| 33 |
+
* Γ’βΊβ MEASURED, not guessed, on a production build. `query.css` was imported ONLY by `QueryPage`,
|
| 34 |
+
* which the Assistant loads with `lazy()`. Vite splits an async chunk's CSS into its own file, so
|
| 35 |
+
* all 44 `.qy-rail*` rules landed in `QueryPage-*.css` and the boot stylesheet carried ZERO of
|
| 36 |
+
* them. But this module Γ’β¬β which renders every one of those classes Γ’β¬β is imported STATICALLY by
|
| 37 |
+
* `AssistantPage`, and the rail it exports sits OUTSIDE the `<Suspense>` boundary the workspace is
|
| 38 |
+
* behind. So flipping the toggle committed the rail in the same frame while its stylesheet was
|
| 39 |
+
* still one network round trip away: browser-default buttons, default `<p>` margins, a blue
|
| 40 |
+
* underlined link. That is the "outdated format".
|
| 41 |
+
*
|
| 42 |
+
* Γ’Ε‘Β It is NOT the Suspense fallback (a bare spinner) and NOT the empty state, the two candidates
|
| 43 |
+
* the wave listed. Capture of both states: `.claude/wiki/waves/wave35/proto/T22-query-flash/`.
|
| 44 |
+
*
|
| 45 |
+
* Γ’Ε‘Β KEEP THE TWIN IMPORT IN `QueryPage.tsx`. Vite emits one stylesheet either way, and the
|
| 46 |
+
* standalone `#/query` host reaches the page without passing through here.
|
| 47 |
+
*/
|
| 48 |
+
import "./query.css";
|
| 49 |
|
| 50 |
/** Which label of which row is being typed into. One at a time, by construction. */
|
| 51 |
interface Editing { id: string; field: "name" | "description"; value: string; }
|
| 52 |
|
| 53 |
/**
|
| 54 |
+
* Γ’βΊβ THE MENU IS `position: fixed`, AND THAT IS NOT A STYLE PREFERENCE. Its natural home,
|
| 55 |
* `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
|
| 56 |
* on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
|
| 57 |
* nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
|
| 58 |
* Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
|
| 59 |
*
|
| 60 |
+
* Γ’Ε‘Β `MENU_H` is an estimate used ONLY to decide whether to flip upward near the viewport floor. It
|
| 61 |
* does not size anything, so being a little wrong shifts the menu rather than breaking it.
|
| 62 |
*/
|
| 63 |
const MENU_H = 190;
|
|
|
|
| 91 |
);
|
| 92 |
}
|
| 93 |
|
| 94 |
+
/** The kind glyph a Query row wears Γ’β¬β the same vocabulary the grid's own view rail uses. */
|
| 95 |
const KIND_MARK: Record<string, string> = {
|
| 96 |
grid: "Table", list: "List", chart: "Chart", kanban: "Board",
|
| 97 |
calendar: "Calendar", timeseries: "Time series", map: "Map",
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
/**
|
| 108 |
+
* Γ’Ε‘Β GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
|
| 109 |
* DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
|
| 110 |
+
* Customers must not move the Customers heading Γ’β¬β it appears under it. Within a database the
|
| 111 |
* newest is first, which is the order the server already sends.
|
| 112 |
*
|
| 113 |
+
* Γ’ΒΒ R14 GAVE THIS LIST A SECOND HOST, which is why the loop is a function and lives HERE rather
|
| 114 |
* than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
|
| 115 |
* panel; two hosts computing "which views belong to which database" from two copies of one loop
|
| 116 |
* is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
|
| 117 |
* It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
|
| 118 |
+
* Γ’β¬β a static import from `QueryPage.tsx` would pull `CustomerGrid` into the chat bundle and undo
|
| 119 |
* the lazy boundary the Assistant keeps around it.
|
| 120 |
*
|
| 121 |
* A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
/**
|
| 145 |
+
* Γ’ΒΒΓ’ΒΒ OWNER ITEM 3 (2026-08-15) Γ’β¬β GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
|
| 146 |
* Verbatim: *"these AI generated query should live under the query module. And that each View
|
| 147 |
* correspond to the relevant database. So we need to remove that dumb dropdown that suddenly
|
| 148 |
* appears at the top right of the Database when the AI does query. That's not how we should do
|
| 149 |
* the navigation."*
|
| 150 |
*
|
| 151 |
+
* Γ’βΊβ THE DROPDOWN WAS NOT THE DEFECT Γ’β¬β THE ABSENCE OF NAVIGATION WAS. Deleting it (which the
|
| 152 |
* previous pass did) left Query with no way to move between artefacts at all: the page picked
|
| 153 |
* `known[0]` and every other view the assistant had ever built was unreachable. A surface with
|
| 154 |
* one implicit selection is not "no dropdown", it is no navigation, and the owner asked for the
|
| 155 |
* navigation to be done differently rather than removed.
|
| 156 |
*
|
| 157 |
+
* Γ’Ε‘Β AND THIS IS WHERE A DATABASE PUTS ITS VIEWS. `dbFrame.tsx` carries the owner's earlier
|
| 158 |
+
* sentence Γ’β¬β *"the query module should exactly BE looking like the database module, COMPLETELY,
|
| 159 |
+
* with all the views etc."* Γ’β¬β so the rail sits in the slot `CustomerGrid`'s own `ViewSidebar`
|
| 160 |
* occupies on every other database, at the same `--lp-rail-w`. It is a separate component only
|
| 161 |
* because THIS list spans several databases and the grid's rail, by construction, cannot: the
|
| 162 |
* grid is mounted at one scope.
|
|
|
|
| 167 |
onSelect: (id: string) => void;
|
| 168 |
onDelete: (id: string) => void;
|
| 169 |
/**
|
| 170 |
+
* Γ’ΒΒ The rail OWNS the rename/duplicate calls and hands the host the resulting artefact to
|
| 171 |
* upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
|
| 172 |
* putting the calls in the hosts would be one question with two implementations, and the copy
|
| 173 |
* that drifts is the one nobody is looking at.
|
|
|
|
| 180 |
const [menuFor, setMenuFor] = useState("");
|
| 181 |
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
|
| 182 |
const [editing, setEditing] = useState<Editing | null>(null);
|
| 183 |
+
// Γ’Ε‘Β TWO STATES, NOT ONE. A refusal and a confirmation read differently and are coloured
|
| 184 |
// differently; sharing one slot painted "Saved as ..." in the refusal red.
|
| 185 |
const [problem, setProblem] = useState("");
|
| 186 |
const [notice, setNotice] = useState("");
|
| 187 |
+
/**
|
| 188 |
+
* β C2's star list, read AFTER PAINT and never blocking the rail (E-2: `GET /starred` scans
|
| 189 |
+
* every visible database's view bucket and was MEASURED at 7,331 ms on a cold container). The
|
| 190 |
+
* hook fires in an effect and reports `phase`, so the control simply is not drawn until the
|
| 191 |
+
* answer arrives β a star toggled from a list nobody has read would be a guess at its own
|
| 192 |
+
* current value.
|
| 193 |
+
*/
|
| 194 |
+
const stars = useStarred();
|
| 195 |
+
const starred = new Set(stars.starred.queries);
|
| 196 |
const said = (message: string) => { setNotice(message); setProblem(""); };
|
| 197 |
const failed = (message: string) => { setProblem(message); setNotice(""); };
|
| 198 |
|
|
|
|
| 208 |
};
|
| 209 |
|
| 210 |
/**
|
| 211 |
+
* Γ’Ε‘Β THE REPLY IS SHOWN WHETHER OR NOT ANYTHING WAS DROPPED, and the dropped half is the useful
|
| 212 |
* one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
|
| 213 |
* the sum. Saying so beats a view that looks complete and answers a different question.
|
| 214 |
*/
|
|
|
|
| 220 |
: `Saved as "${result.value.name}" in this database's own views.`);
|
| 221 |
};
|
| 222 |
|
| 223 |
+
/**
|
| 224 |
+
* ββ W35-T25 Β· CONTRACT C4 (R3) β "Revert to AI original", and the sentence comes FIRST.
|
| 225 |
+
*
|
| 226 |
+
* β THE CONFIRM SAYS WHAT REVERT DOES **NOT** DO, BEFORE IT ACTS, AND R3 REQUIRES THAT WORDING
|
| 227 |
+
* RATHER THAN SUGGESTING IT. A Query view is live since R2, so a cell somebody changed through
|
| 228 |
+
* it is a real write to a real record in the source database. Putting the filters back cannot
|
| 229 |
+
* walk that back, and a reader who assumed otherwise has been told the opposite of the truth at
|
| 230 |
+
* the one moment they could still have chosen differently.
|
| 231 |
+
*
|
| 232 |
+
* β `window.confirm`, deliberately, and this is the one place in this module that uses it. Every
|
| 233 |
+
* other destructive door here is a two-click in the row, which works because those are cheap and
|
| 234 |
+
* reversible (a rename, a delete of one saved answer). This one is neither: it discards work with
|
| 235 |
+
* no undo, and a two-click cannot carry a sentence.
|
| 236 |
+
*/
|
| 237 |
+
const revert = async (view: SavedQuery) => {
|
| 238 |
+
setMenuFor("");
|
| 239 |
+
const ok = window.confirm(
|
| 240 |
+
`Revert "${view.name}" to the version the assistant built?\n\n`
|
| 241 |
+
+ "This restores the filters, sorting, grouping, visible columns and column widths.\n\n"
|
| 242 |
+
+ "It does NOT undo any record you changed in the source database. Those edits stay.");
|
| 243 |
+
if (!ok) return;
|
| 244 |
+
const result = await revertQuery(view.id);
|
| 245 |
+
if (!result.ok) { failed(result.message); return; }
|
| 246 |
+
said(`"${result.value.name}" is back to the assistant's version.`);
|
| 247 |
+
onChanged?.(result.value);
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
const duplicate = async (id: string) => {
|
| 251 |
const result = await duplicateQuery(id);
|
| 252 |
+
// Γ’Ε‘Β The refusal is the useful half: MAX_ARTIFACTS names its cause and the remedy, and a copy
|
| 253 |
// that silently did not happen is the failure mode this whole module is written against.
|
| 254 |
if (!result.ok) { failed(result.message); return; }
|
| 255 |
said("");
|
|
|
|
| 297 |
title={view.description || view.question}
|
| 298 |
>
|
| 299 |
<span className="qy-rail-name">{view.name}</span>
|
| 300 |
+
<span className="qy-rail-kind">
|
| 301 |
+
{KIND_MARK[view.kind] ?? view.kind}
|
| 302 |
+
{/* ββ W35-T25 (R3) β THE EDITED MARK, ON THE ROW THAT NAMES THE VIEW.
|
| 303 |
+
R3 says "its header"; since owner item 3 replaced the top-right dropdown
|
| 304 |
+
with this rail, this row IS a Query view's header, and owner item 4 has
|
| 305 |
+
just deleted the only strip above the grid. Putting the badge back up
|
| 306 |
+
there would re-grow the row the same wave removed.
|
| 307 |
+
β Absent, not false, for an artefact with no `original_spec`: `edited` is
|
| 308 |
+
derived server-side by comparing against it, so a pre-wave artefact
|
| 309 |
+
reports `false` and shows neither this nor the Revert row (C4). */}
|
| 310 |
+
{view.edited ? (
|
| 311 |
+
<span className="qy-rail-edited"
|
| 312 |
+
title="You changed this view. Revert to AI original is in its menu.">
|
| 313 |
+
Edited
|
| 314 |
+
</span>
|
| 315 |
+
) : null}
|
| 316 |
+
</span>
|
| 317 |
</button>
|
| 318 |
)}
|
| 319 |
+
{/* β W35-T47 β the star, on the row, in BOTH hosts (this rail is the only place a
|
| 320 |
+
Query view is listed, and the standalone page and the Assistant's Query mode
|
| 321 |
+
both render it). Not drawn until `/starred` has answered: see `stars` above. */}
|
| 322 |
+
{stars.load.phase === "ready" ? (
|
| 323 |
+
<button
|
| 324 |
+
type="button"
|
| 325 |
+
className={"st-star" + (starred.has(view.id) ? " is-on" : "")}
|
| 326 |
+
aria-pressed={starred.has(view.id)}
|
| 327 |
+
aria-label={(starred.has(view.id) ? "Unstar " : "Star ") + view.name}
|
| 328 |
+
title={(starred.has(view.id) ? "Unstar " : "Star ") + view.name}
|
| 329 |
+
disabled={stars.busy}
|
| 330 |
+
onClick={() => stars.toggle("query", view.id, !starred.has(view.id))}
|
| 331 |
+
>
|
| 332 |
+
<StarIcon size={15} filled={starred.has(view.id)} />
|
| 333 |
+
</button>
|
| 334 |
+
) : null}
|
| 335 |
{confirming === view.id ? (
|
| 336 |
<button type="button" className="qy-rail-confirm"
|
| 337 |
onClick={() => { setConfirming(""); onDelete(view.id); }}>
|
|
|
|
| 374 |
</button>
|
| 375 |
<button type="button" className="qy-rail-item" role="menuitem"
|
| 376 |
onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
|
| 377 |
+
{/* β R3: armed only when there IS an original AND the view has moved from it.
|
| 378 |
+
A Revert that reverts to nothing is worse than an absent one (C4), and a
|
| 379 |
+
Revert on an unedited view is a control that does nothing. */}
|
| 380 |
+
{view.edited && view.original_spec ? (
|
| 381 |
+
<button type="button" className="qy-rail-item" role="menuitem"
|
| 382 |
+
onClick={() => { void revert(view); }}>
|
| 383 |
+
Revert to AI original
|
| 384 |
+
</button>
|
| 385 |
+
) : null}
|
| 386 |
{/* R20's one difference from a database view's menu: this one can BECOME one. */}
|
| 387 |
<button type="button" className="qy-rail-item" role="menuitem"
|
| 388 |
onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
|
|
|
|
| 405 |
);
|
| 406 |
}
|
| 407 |
|
| 408 |
+
/*
|
| 409 |
+
* Γ’βΊβΓ’βΊβ `QueryProvenance` IS DELETED (W35-T21, owner item 4 / R2), AND WHAT WENT WITH IT MATTERS.
|
| 410 |
+
* Owner, verbatim: *"there is a 'Source' button under Query as well. The end user doesn't need to
|
| 411 |
+
* see this so delete it."* R2 retires the snapshot framing with it, because the two were one
|
| 412 |
+
* control: the button read "Sources" and the row beside it read the snapshot version.
|
| 413 |
*
|
| 414 |
+
* Γ’Ε‘Β THE CITATIONS DID NOT GO. They still reach the grid through `bindingFor(active, citations)`
|
| 415 |
+
* in `QueryPage`, and they are still RENDERED for a reader in the surface that owns the claim Γ’β¬β
|
| 416 |
+
* the chat, where `AssistantPage::CitationLine` prints one clickable line per citation under the
|
| 417 |
+
* answer that used it. What R2 removed is a second provenance surface above a grid, not the
|
| 418 |
+
* auditability R9 asked for. `verify_grid_ux.py::query_preview_shape` asserts exactly that pair:
|
| 419 |
+
* no provenance chrome on this page, citations still bound.
|
| 420 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/settings/AdminPane.tsx
CHANGED
|
@@ -1,646 +1,646 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// settings / AdminPane.tsx β THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
|
| 3 |
-
// R3+R4, contract C2).
|
| 4 |
-
//
|
| 5 |
-
// The one surface in this product that looks ACROSS tenants: who our customers
|
| 6 |
-
// are, how many people use each workspace, what they have built in it, whether
|
| 7 |
-
// their data sources are alive, and what the automation fleet costs.
|
| 8 |
-
//
|
| 9 |
-
// SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
|
| 10 |
-
// `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
|
| 11 |
-
// line in the pane switch β `SettingsModal.tsx` is another session's file this
|
| 12 |
-
// wave and must not have to learn anything about this pane's data.
|
| 13 |
-
//
|
| 14 |
-
// β THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
|
| 15 |
-
// The mount site passes the shell's session user (a wider object), which
|
| 16 |
-
// satisfies this shape by structural typing β and this pane therefore imports
|
| 17 |
-
// nothing from the shell, which is what the wave's cross-fence rule asks for
|
| 18 |
-
// while `session.ts` is open on another desk.
|
| 19 |
-
//
|
| 20 |
-
// β EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
|
| 21 |
-
// opens the rows it was computed from β the same collector, projected twice
|
| 22 |
-
// ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
|
| 23 |
-
// em dash with the reason, never as a zero: "this customer has no databases" and
|
| 24 |
-
// "we could not look" are different facts and the pane refuses to conflate them.
|
| 25 |
-
//
|
| 26 |
-
// DESIGN: sentence-case micro-labels (R6 β no caps in app chrome), short noun
|
| 27 |
-
// headers, tabular figures right-aligned, hairlines not shadows, tokens only,
|
| 28 |
-
// no emojis (DESIGN.md Β§2βΒ§4).
|
| 29 |
-
// ---------------------------------------------------------------------------
|
| 30 |
-
|
| 31 |
-
import { useCallback, useEffect, useState } from "react";
|
| 32 |
-
import type {
|
| 33 |
-
AutomationRow,
|
| 34 |
-
ConnectorRow,
|
| 35 |
-
DatabaseRow,
|
| 36 |
-
FleetCost,
|
| 37 |
-
Overview,
|
| 38 |
-
PlatformUser,
|
| 39 |
-
ReleasesPayload,
|
| 40 |
-
} from "./platformAdminApi";
|
| 41 |
-
import {
|
| 42 |
-
getAutomations,
|
| 43 |
-
getAws,
|
| 44 |
-
getReleases,
|
| 45 |
-
getConnectors,
|
| 46 |
-
getDatabases,
|
| 47 |
-
getOverview,
|
| 48 |
-
getUsers,
|
| 49 |
-
} from "./platformAdminApi";
|
| 50 |
-
|
| 51 |
-
// --- formatting -------------------------------------------------------------
|
| 52 |
-
|
| 53 |
-
const int = (n: number) => n.toLocaleString();
|
| 54 |
-
|
| 55 |
-
/** A count that may be unknown. `null` is NOT zero β see the api module's header. */
|
| 56 |
-
function Count({ n, why }: { n: number | null | undefined; why?: string }) {
|
| 57 |
-
if (n === null || n === undefined)
|
| 58 |
-
return (
|
| 59 |
-
<span className="padmin-unknown" title={why || "This could not be read just now"}>
|
| 60 |
-
β
|
| 61 |
-
</span>
|
| 62 |
-
);
|
| 63 |
-
return <>{int(n)}</>;
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
/** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
|
| 67 |
-
*
|
| 68 |
-
* β ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
|
| 69 |
-
* BROWSER-LOCAL, so running a container-local timestamp through this reports the
|
| 70 |
-
* viewer's UTC offset as elapsed time β hours of error in a column people scan,
|
| 71 |
-
* and a future-dated stamp that renders as "just now" indefinitely. Everything
|
| 72 |
-
* this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
|
| 73 |
-
* with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
|
| 74 |
-
* rendered verbatim instead. */
|
| 75 |
-
function ago(stamp: string): string {
|
| 76 |
-
if (!stamp) return "never";
|
| 77 |
-
const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
|
| 78 |
-
if (Number.isNaN(t)) return stamp;
|
| 79 |
-
const mins = Math.floor((Date.now() - t) / 60000);
|
| 80 |
-
if (mins < 2) return "just now";
|
| 81 |
-
if (mins < 60) return `${mins} minutes ago`;
|
| 82 |
-
const hours = Math.floor(mins / 60);
|
| 83 |
-
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
| 84 |
-
const days = Math.floor(hours / 24);
|
| 85 |
-
if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
|
| 86 |
-
return `${Math.floor(days / 30)} months ago`;
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
type DrillKind = "users" | "databases" | "connectors" | "automations";
|
| 90 |
-
|
| 91 |
-
const DRILL_NOUN: Record<DrillKind, string> = {
|
| 92 |
-
users: "People",
|
| 93 |
-
databases: "Databases",
|
| 94 |
-
connectors: "Data sources",
|
| 95 |
-
automations: "Automations",
|
| 96 |
-
};
|
| 97 |
-
|
| 98 |
-
/** The loading shape β a skeleton, not a spinner (DESIGN.md Β§4). */
|
| 99 |
-
function Skeleton({ rows = 3 }: { rows?: number }) {
|
| 100 |
-
return (
|
| 101 |
-
<div className="padmin-skel" aria-hidden="true">
|
| 102 |
-
{Array.from({ length: rows }, (_, i) => (
|
| 103 |
-
<div key={i} className="padmin-skel-row" />
|
| 104 |
-
))}
|
| 105 |
-
</div>
|
| 106 |
-
);
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
// --- the pane ---------------------------------------------------------------
|
| 110 |
-
|
| 111 |
-
export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
|
| 112 |
-
const [ov, setOv] = useState<Overview | null>(null);
|
| 113 |
-
const [err, setErr] = useState("");
|
| 114 |
-
const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
|
| 115 |
-
|
| 116 |
-
const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
|
| 117 |
-
const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
|
| 118 |
-
const [drillNote, setDrillNote] = useState("");
|
| 119 |
-
|
| 120 |
-
const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
|
| 121 |
-
// Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand β it is
|
| 122 |
-
// two small Hub reads and the first question an operator opens this pane to answer.
|
| 123 |
-
const [rel, setRel] = useState<ReleasesPayload | null>(null);
|
| 124 |
-
const [awsBusy, setAwsBusy] = useState(false);
|
| 125 |
-
|
| 126 |
-
const load = useCallback(() => {
|
| 127 |
-
setOv(null);
|
| 128 |
-
void getOverview().then((r) => {
|
| 129 |
-
if (r.ok) {
|
| 130 |
-
setOv(r.data);
|
| 131 |
-
setErr("");
|
| 132 |
-
} else setErr(r.message);
|
| 133 |
-
});
|
| 134 |
-
void getAutomations().then((r) => {
|
| 135 |
-
if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
|
| 136 |
-
});
|
| 137 |
-
void getReleases().then((r) => {
|
| 138 |
-
if (r.ok) setRel(r.data);
|
| 139 |
-
});
|
| 140 |
-
}, []);
|
| 141 |
-
useEffect(load, [load]);
|
| 142 |
-
|
| 143 |
-
const openDrill = useCallback((kind: DrillKind, tenant: string) => {
|
| 144 |
-
setDrill({ kind, tenant });
|
| 145 |
-
setDrillRows(null);
|
| 146 |
-
setDrillNote("");
|
| 147 |
-
const fetcher =
|
| 148 |
-
kind === "users"
|
| 149 |
-
? getUsers
|
| 150 |
-
: kind === "databases"
|
| 151 |
-
? getDatabases
|
| 152 |
-
: kind === "connectors"
|
| 153 |
-
? getConnectors
|
| 154 |
-
: getAutomations;
|
| 155 |
-
void fetcher(tenant || undefined).then((r) => {
|
| 156 |
-
if (!r.ok) {
|
| 157 |
-
setDrillRows([]);
|
| 158 |
-
setDrillNote(r.message);
|
| 159 |
-
return;
|
| 160 |
-
}
|
| 161 |
-
const d = r.data as Record<string, unknown>;
|
| 162 |
-
setDrillRows((d[kind] as unknown[]) ?? []);
|
| 163 |
-
// The server's own honest note travels with the rows rather than being
|
| 164 |
-
// re-invented here: it is the one that knows WHY a tenant is missing.
|
| 165 |
-
const errors = (d.errors as Record<string, string>) ?? {};
|
| 166 |
-
const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
|
| 167 |
-
setDrillNote(
|
| 168 |
-
[typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
|
| 169 |
-
.filter(Boolean)
|
| 170 |
-
.join(" Β· ")
|
| 171 |
-
);
|
| 172 |
-
});
|
| 173 |
-
}, []);
|
| 174 |
-
|
| 175 |
-
const loadAws = useCallback(() => {
|
| 176 |
-
setAwsBusy(true);
|
| 177 |
-
void getAws(7).then((r) => {
|
| 178 |
-
setAwsBusy(false);
|
| 179 |
-
if (r.ok) setAws(r.data.report);
|
| 180 |
-
else setAws({ text: "", note: r.message, available: false });
|
| 181 |
-
});
|
| 182 |
-
}, []);
|
| 183 |
-
|
| 184 |
-
return (
|
| 185 |
-
<div className="set-pane">
|
| 186 |
-
<h3 className="set-h">Loopable admin</h3>
|
| 187 |
-
<p className="set-help set-pane-intro">
|
| 188 |
-
Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
|
| 189 |
-
computed from; anything that could not be read shows a dash and says why, rather than a
|
| 190 |
-
zero.
|
| 191 |
-
</p>
|
| 192 |
-
{err ? <p className="set-error">{err}</p> : null}
|
| 193 |
-
|
| 194 |
-
{!ov ? (
|
| 195 |
-
<Skeleton rows={4} />
|
| 196 |
-
) : (
|
| 197 |
-
<>
|
| 198 |
-
<div className="padmin-totals">
|
| 199 |
-
<Total label="Workspaces" value={int(ov.totals.tenants)} />
|
| 200 |
-
<Total label="People" value={int(ov.totals.users)} />
|
| 201 |
-
<Total label="Databases" value={int(ov.totals.databases)} />
|
| 202 |
-
<Total label="Records" value={int(ov.totals.rows)} />
|
| 203 |
-
<Total
|
| 204 |
-
label="Automation cost"
|
| 205 |
-
value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "β"}
|
| 206 |
-
hint={fleet?.cost.basis}
|
| 207 |
-
/>
|
| 208 |
-
</div>
|
| 209 |
-
{ov.totals.unknownTenants > 0 ? (
|
| 210 |
-
<p className="set-help padmin-caveat">
|
| 211 |
-
{ov.totals.unknownTenants} workspace
|
| 212 |
-
{ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
|
| 213 |
-
exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
|
| 214 |
-
</p>
|
| 215 |
-
) : null}
|
| 216 |
-
|
| 217 |
-
<div className="padmin-tablewrap">
|
| 218 |
-
<table className="padmin-table">
|
| 219 |
-
<thead>
|
| 220 |
-
<tr>
|
| 221 |
-
<th>Workspace</th>
|
| 222 |
-
<th>Storage</th>
|
| 223 |
-
<th className="padmin-num">People</th>
|
| 224 |
-
<th className="padmin-num">Databases</th>
|
| 225 |
-
<th className="padmin-num">Records</th>
|
| 226 |
-
<th className="padmin-num">Sources</th>
|
| 227 |
-
<th className="padmin-num">Automations</th>
|
| 228 |
-
</tr>
|
| 229 |
-
</thead>
|
| 230 |
-
<tbody>
|
| 231 |
-
{ov.tenants.map((t) => (
|
| 232 |
-
<tr key={t.slug}>
|
| 233 |
-
<td>
|
| 234 |
-
<span className="padmin-name">{t.name}</span>
|
| 235 |
-
<span className="padmin-meta">
|
| 236 |
-
{t.slug}
|
| 237 |
-
{t.status !== "active" ? ` Β· ${t.status}` : ""}
|
| 238 |
-
{t.errors && t.errors.length ? ` Β· ${t.errors[0]}` : ""}
|
| 239 |
-
</span>
|
| 240 |
-
</td>
|
| 241 |
-
<td>
|
| 242 |
-
<span className="padmin-meta">
|
| 243 |
-
{t.storeRepo && t.storeRepo.includes("/")
|
| 244 |
-
? "own repository"
|
| 245 |
-
: t.storePrefix
|
| 246 |
-
? "shared repository"
|
| 247 |
-
: "tenant #0 repository"}
|
| 248 |
-
{t.keychainLocked ? " Β· keychain locked" : ""}
|
| 249 |
-
</span>
|
| 250 |
-
</td>
|
| 251 |
-
<DrillCell
|
| 252 |
-
n={t.users}
|
| 253 |
-
onOpen={() => openDrill("users", t.slug)}
|
| 254 |
-
title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
|
| 255 |
-
/>
|
| 256 |
-
<DrillCell
|
| 257 |
-
n={t.databases}
|
| 258 |
-
onOpen={() => openDrill("databases", t.slug)}
|
| 259 |
-
why={t.errors?.[0]}
|
| 260 |
-
/>
|
| 261 |
-
<DrillCell
|
| 262 |
-
n={t.rows}
|
| 263 |
-
onOpen={() => openDrill("databases", t.slug)}
|
| 264 |
-
why={t.errors?.[0]}
|
| 265 |
-
/>
|
| 266 |
-
<DrillCell
|
| 267 |
-
n={t.connectors}
|
| 268 |
-
onOpen={() => openDrill("connectors", t.slug)}
|
| 269 |
-
why={t.errors?.[0]}
|
| 270 |
-
title={
|
| 271 |
-
t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
|
| 272 |
-
}
|
| 273 |
-
/>
|
| 274 |
-
<DrillCell
|
| 275 |
-
n={t.automations}
|
| 276 |
-
onOpen={() => openDrill("automations", t.slug)}
|
| 277 |
-
why={t.errors?.[0]}
|
| 278 |
-
title={
|
| 279 |
-
t.automationsEnabled != null
|
| 280 |
-
? `${t.automationsEnabled} scheduled`
|
| 281 |
-
: undefined
|
| 282 |
-
}
|
| 283 |
-
/>
|
| 284 |
-
</tr>
|
| 285 |
-
))}
|
| 286 |
-
</tbody>
|
| 287 |
-
</table>
|
| 288 |
-
</div>
|
| 289 |
-
|
| 290 |
-
<p className="set-help padmin-caveat">
|
| 291 |
-
Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
|
| 292 |
-
{ov.totals.orphanUsers > 0
|
| 293 |
-
? `${ov.totals.orphanUsers} account${
|
| 294 |
-
ov.totals.orphanUsers === 1 ? "" : "s"
|
| 295 |
-
} belong to a workspace this deployment no longer knows. `
|
| 296 |
-
: ""}
|
| 297 |
-
<button type="button" className="padmin-link" onClick={load}>
|
| 298 |
-
Refresh
|
| 299 |
-
</button>
|
| 300 |
-
</p>
|
| 301 |
-
|
| 302 |
-
{drill ? (
|
| 303 |
-
<section className="padmin-drill">
|
| 304 |
-
<div className="padmin-drill-head">
|
| 305 |
-
<h4 className="padmin-h4">
|
| 306 |
-
{DRILL_NOUN[drill.kind]}
|
| 307 |
-
{drill.tenant ? `
|
| 308 |
-
</h4>
|
| 309 |
-
<button type="button" className="padmin-link" onClick={() => setDrill(null)}>
|
| 310 |
-
Close
|
| 311 |
-
</button>
|
| 312 |
-
</div>
|
| 313 |
-
{drillRows === null ? (
|
| 314 |
-
<Skeleton rows={2} />
|
| 315 |
-
) : drillRows.length === 0 ? (
|
| 316 |
-
<p className="set-help">Nothing here yet.</p>
|
| 317 |
-
) : (
|
| 318 |
-
<DrillTable kind={drill.kind} rows={drillRows} />
|
| 319 |
-
)}
|
| 320 |
-
{drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
|
| 321 |
-
</section>
|
| 322 |
-
) : null}
|
| 323 |
-
|
| 324 |
-
{fleet ? (
|
| 325 |
-
<section className="padmin-block">
|
| 326 |
-
{/* Named for what it SHOWS. The fleet's rows live one level down,
|
| 327 |
-
behind the Automations count β this section is the cost and the
|
| 328 |
-
reasoning behind it, so calling it "fleet" would promise a
|
| 329 |
-
table that is deliberately not here. */}
|
| 330 |
-
<h4 className="padmin-h4">Automation cost</h4>
|
| 331 |
-
<p className="set-help">
|
| 332 |
-
{fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
|
| 333 |
-
{fleet.cost.fleetRunsPerDay
|
| 334 |
-
? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
|
| 335 |
-
: ""}
|
| 336 |
-
{fleet.cost.unknownCadence
|
| 337 |
-
? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
|
| 338 |
-
: ""}
|
| 339 |
-
. {fleet.cost.basis}
|
| 340 |
-
</p>
|
| 341 |
-
</section>
|
| 342 |
-
) : null}
|
| 343 |
-
|
| 344 |
-
{/* ββ Wave 20 (owner item 12 / R6): what is running where ββββββββββββββββββ
|
| 345 |
-
READ-ONLY BY RULING. The operator sees both environments and every cut
|
| 346 |
-
release; moving one is a CLI command, printed below rather than wired to
|
| 347 |
-
a button, so no browser session can rewrite production. */}
|
| 348 |
-
<section className="padmin-block">
|
| 349 |
-
<h4 className="padmin-h4">Releases</h4>
|
| 350 |
-
{!rel ? (
|
| 351 |
-
<Skeleton rows={2} />
|
| 352 |
-
) : (
|
| 353 |
-
<>
|
| 354 |
-
<table className="padmin-table">
|
| 355 |
-
<thead>
|
| 356 |
-
<tr>
|
| 357 |
-
<th>Environment</th>
|
| 358 |
-
<th>Version</th>
|
| 359 |
-
<th>Stage</th>
|
| 360 |
-
<th>Space</th>
|
| 361 |
-
</tr>
|
| 362 |
-
</thead>
|
| 363 |
-
<tbody>
|
| 364 |
-
{rel.environments.map((e) => (
|
| 365 |
-
<tr key={e.env}>
|
| 366 |
-
<td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td>
|
| 367 |
-
{/* β An unreadable version is a NOTE, never a blank. A blank cell in a
|
| 368 |
-
column headed "Version" reads as "nothing is deployed", which about a
|
| 369 |
-
production environment is the worst available way to be wrong. */}
|
| 370 |
-
<td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td>
|
| 371 |
-
<td>{e.stage || "β"}</td>
|
| 372 |
-
{/* The Space ID as TEXT, not a link: the server deliberately builds no
|
| 373 |
-
URL (portability C1 β a host literal in runtime code hard-codes the
|
| 374 |
-
current host into a process designed to move). */}
|
| 375 |
-
<td>{e.space}</td>
|
| 376 |
-
</tr>
|
| 377 |
-
))}
|
| 378 |
-
</tbody>
|
| 379 |
-
</table>
|
| 380 |
-
{rel.releases.length ? (
|
| 381 |
-
<table className="padmin-table">
|
| 382 |
-
<thead>
|
| 383 |
-
<tr>
|
| 384 |
-
<th>Release</th>
|
| 385 |
-
<th>Commit</th>
|
| 386 |
-
<th>Cut</th>
|
| 387 |
-
<th>What shipped</th>
|
| 388 |
-
</tr>
|
| 389 |
-
</thead>
|
| 390 |
-
<tbody>
|
| 391 |
-
{rel.releases.map((r) => (
|
| 392 |
-
<tr key={r.version}>
|
| 393 |
-
<td>{r.version}</td>
|
| 394 |
-
<td>{r.sha}</td>
|
| 395 |
-
<td>{r.date}</td>
|
| 396 |
-
<td>{r.subject}</td>
|
| 397 |
-
</tr>
|
| 398 |
-
))}
|
| 399 |
-
</tbody>
|
| 400 |
-
</table>
|
| 401 |
-
) : (
|
| 402 |
-
<p className="set-help">
|
| 403 |
-
No tagged releases were readable from either Space.
|
| 404 |
-
</p>
|
| 405 |
-
)}
|
| 406 |
-
<p className="set-help">
|
| 407 |
-
To move Live to another version, or roll it back, run:{" "}
|
| 408 |
-
<code>{rel.promote}</code>
|
| 409 |
-
</p>
|
| 410 |
-
</>
|
| 411 |
-
)}
|
| 412 |
-
</section>
|
| 413 |
-
|
| 414 |
-
<section className="padmin-block">
|
| 415 |
-
<h4 className="padmin-h4">AWS cron</h4>
|
| 416 |
-
{!aws ? (
|
| 417 |
-
<p className="set-help">
|
| 418 |
-
The external tick that wakes this app on schedule. Reading its usage runs a live
|
| 419 |
-
CloudWatch query and takes a few seconds.{" "}
|
| 420 |
-
<button
|
| 421 |
-
type="button"
|
| 422 |
-
className="padmin-link"
|
| 423 |
-
onClick={loadAws}
|
| 424 |
-
disabled={awsBusy}
|
| 425 |
-
>
|
| 426 |
-
{awsBusy ? "Readingβ¦" : "Check usage"}
|
| 427 |
-
</button>
|
| 428 |
-
</p>
|
| 429 |
-
) : aws.available ? (
|
| 430 |
-
<pre className="padmin-pre">{aws.text}</pre>
|
| 431 |
-
) : (
|
| 432 |
-
<p className="set-help">{aws.note}</p>
|
| 433 |
-
)}
|
| 434 |
-
</section>
|
| 435 |
-
</>
|
| 436 |
-
)}
|
| 437 |
-
</div>
|
| 438 |
-
);
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
| 442 |
-
// A tooltip nobody can see is a tooltip nobody reads: the label carries a
|
| 443 |
-
// dotted underline exactly when there is something behind it.
|
| 444 |
-
return (
|
| 445 |
-
<div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
|
| 446 |
-
<span className="padmin-total-label">{label}</span>
|
| 447 |
-
<span className="padmin-total-value">{value}</span>
|
| 448 |
-
</div>
|
| 449 |
-
);
|
| 450 |
-
}
|
| 451 |
-
|
| 452 |
-
/** A numeric cell that opens its own rows. Unknown counts are not clickable β
|
| 453 |
-
* there is nothing to drill INTO when the read failed, and a button that opens
|
| 454 |
-
* an empty table would read as "none". */
|
| 455 |
-
function DrillCell({
|
| 456 |
-
n,
|
| 457 |
-
onOpen,
|
| 458 |
-
why,
|
| 459 |
-
title,
|
| 460 |
-
}: {
|
| 461 |
-
n: number | null | undefined;
|
| 462 |
-
onOpen: () => void;
|
| 463 |
-
why?: string;
|
| 464 |
-
title?: string;
|
| 465 |
-
}) {
|
| 466 |
-
if (n === null || n === undefined)
|
| 467 |
-
return (
|
| 468 |
-
<td className="padmin-num">
|
| 469 |
-
<Count n={n} why={why} />
|
| 470 |
-
</td>
|
| 471 |
-
);
|
| 472 |
-
return (
|
| 473 |
-
<td className="padmin-num">
|
| 474 |
-
<button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
|
| 475 |
-
{int(n)}
|
| 476 |
-
</button>
|
| 477 |
-
</td>
|
| 478 |
-
);
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
|
| 482 |
-
if (kind === "users") {
|
| 483 |
-
const rs = rows as PlatformUser[];
|
| 484 |
-
return (
|
| 485 |
-
<div className="padmin-tablewrap">
|
| 486 |
-
<table className="padmin-table">
|
| 487 |
-
<thead>
|
| 488 |
-
<tr>
|
| 489 |
-
<th>Person</th>
|
| 490 |
-
<th>Workspace</th>
|
| 491 |
-
<th>Role</th>
|
| 492 |
-
<th>Last sign-in</th>
|
| 493 |
-
<th>Last active</th>
|
| 494 |
-
</tr>
|
| 495 |
-
</thead>
|
| 496 |
-
<tbody>
|
| 497 |
-
{rs.map((u) => (
|
| 498 |
-
<tr key={`${u.tenant}/${u.username}`}>
|
| 499 |
-
<td>
|
| 500 |
-
<span className="padmin-name">{u.name}</span>
|
| 501 |
-
<span className="padmin-meta">
|
| 502 |
-
{u.username}
|
| 503 |
-
{u.email ? ` Β· ${u.email}` : ""}
|
| 504 |
-
{u.active ? "" : " Β· deactivated"}
|
| 505 |
-
</span>
|
| 506 |
-
</td>
|
| 507 |
-
<td>{u.tenant}</td>
|
| 508 |
-
<td>
|
| 509 |
-
{u.role}
|
| 510 |
-
{u.platformAdmin ? " Β· platform" : ""}
|
| 511 |
-
</td>
|
| 512 |
-
<td>{ago(u.lastLogin)}</td>
|
| 513 |
-
<td>{ago(u.lastActive)}</td>
|
| 514 |
-
</tr>
|
| 515 |
-
))}
|
| 516 |
-
</tbody>
|
| 517 |
-
</table>
|
| 518 |
-
</div>
|
| 519 |
-
);
|
| 520 |
-
}
|
| 521 |
-
if (kind === "databases") {
|
| 522 |
-
const rs = rows as DatabaseRow[];
|
| 523 |
-
return (
|
| 524 |
-
<div className="padmin-tablewrap">
|
| 525 |
-
<table className="padmin-table">
|
| 526 |
-
<thead>
|
| 527 |
-
<tr>
|
| 528 |
-
<th>Database</th>
|
| 529 |
-
<th>Workspace</th>
|
| 530 |
-
<th>Built from</th>
|
| 531 |
-
<th className="padmin-num">Fields</th>
|
| 532 |
-
<th className="padmin-num">Records</th>
|
| 533 |
-
</tr>
|
| 534 |
-
</thead>
|
| 535 |
-
<tbody>
|
| 536 |
-
{rs.map((d) => (
|
| 537 |
-
<tr key={`${d.tenant}/${d.key}`}>
|
| 538 |
-
<td>
|
| 539 |
-
<span className="padmin-name">{d.label}</span>
|
| 540 |
-
<span className="padmin-meta">
|
| 541 |
-
{d.key}
|
| 542 |
-
{d.createdBy ? ` Β· ${d.createdBy}` : ""}
|
| 543 |
-
</span>
|
| 544 |
-
</td>
|
| 545 |
-
<td>{d.tenant}</td>
|
| 546 |
-
<td>{d.source}</td>
|
| 547 |
-
<td className="padmin-num">{int(d.fields)}</td>
|
| 548 |
-
<td className="padmin-num">{int(d.rowCount)}</td>
|
| 549 |
-
</tr>
|
| 550 |
-
))}
|
| 551 |
-
</tbody>
|
| 552 |
-
</table>
|
| 553 |
-
</div>
|
| 554 |
-
);
|
| 555 |
-
}
|
| 556 |
-
if (kind === "connectors") {
|
| 557 |
-
const rs = rows as ConnectorRow[];
|
| 558 |
-
return (
|
| 559 |
-
<div className="padmin-tablewrap">
|
| 560 |
-
<table className="padmin-table">
|
| 561 |
-
<thead>
|
| 562 |
-
<tr>
|
| 563 |
-
<th>Source</th>
|
| 564 |
-
<th>Workspace</th>
|
| 565 |
-
<th>Kind</th>
|
| 566 |
-
<th>Status</th>
|
| 567 |
-
</tr>
|
| 568 |
-
</thead>
|
| 569 |
-
<tbody>
|
| 570 |
-
{rs.map((c) => (
|
| 571 |
-
<tr key={`${c.tenant}/${c.key}`}>
|
| 572 |
-
<td>
|
| 573 |
-
<span className="padmin-name">{c.label}</span>
|
| 574 |
-
<span className="padmin-meta">
|
| 575 |
-
{c.source === "env" ? "environment credentials" : "keychain"}
|
| 576 |
-
</span>
|
| 577 |
-
</td>
|
| 578 |
-
<td>{c.tenant}</td>
|
| 579 |
-
<td>{c.type}</td>
|
| 580 |
-
<td>
|
| 581 |
-
<span
|
| 582 |
-
className={
|
| 583 |
-
"padmin-dot " +
|
| 584 |
-
(c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
|
| 585 |
-
}
|
| 586 |
-
/>
|
| 587 |
-
{c.paused ? "paused" : c.active ? "serving" : "stored"}
|
| 588 |
-
</td>
|
| 589 |
-
</tr>
|
| 590 |
-
))}
|
| 591 |
-
</tbody>
|
| 592 |
-
</table>
|
| 593 |
-
</div>
|
| 594 |
-
);
|
| 595 |
-
}
|
| 596 |
-
const rs = rows as AutomationRow[];
|
| 597 |
-
return (
|
| 598 |
-
<div className="padmin-tablewrap">
|
| 599 |
-
<table className="padmin-table">
|
| 600 |
-
<thead>
|
| 601 |
-
<tr>
|
| 602 |
-
<th>Automation</th>
|
| 603 |
-
<th>Workspace</th>
|
| 604 |
-
<th>Schedule</th>
|
| 605 |
-
<th className="padmin-num">Runs a day</th>
|
| 606 |
-
<th>Last run</th>
|
| 607 |
-
</tr>
|
| 608 |
-
</thead>
|
| 609 |
-
<tbody>
|
| 610 |
-
{rs.map((a) => (
|
| 611 |
-
<tr key={`${a.tenant}/${a.id}`}>
|
| 612 |
-
<td>
|
| 613 |
-
<span className="padmin-name">{a.name}</span>
|
| 614 |
-
<span className="padmin-meta">
|
| 615 |
-
{a.kind}
|
| 616 |
-
{a.failedRetained
|
| 617 |
-
? ` Β· ${a.failedRetained} of the last ${a.runsRetained} runs failed`
|
| 618 |
-
: ""}
|
| 619 |
-
</span>
|
| 620 |
-
</td>
|
| 621 |
-
<td>{a.tenant}</td>
|
| 622 |
-
<td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
|
| 623 |
-
<td className="padmin-num">
|
| 624 |
-
{a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "β"}
|
| 625 |
-
</td>
|
| 626 |
-
<td>
|
| 627 |
-
<span
|
| 628 |
-
className={
|
| 629 |
-
"padmin-dot " +
|
| 630 |
-
(a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
|
| 631 |
-
}
|
| 632 |
-
/>
|
| 633 |
-
{/* Verbatim, not relative: the engine writes this stamp with no
|
| 634 |
-
zone (`automation_engine._iso()` is naive local-to-container),
|
| 635 |
-
so "x hours ago" would be wrong by the viewer's offset. */}
|
| 636 |
-
{a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
|
| 637 |
-
</td>
|
| 638 |
-
</tr>
|
| 639 |
-
))}
|
| 640 |
-
</tbody>
|
| 641 |
-
</table>
|
| 642 |
-
</div>
|
| 643 |
-
);
|
| 644 |
-
}
|
| 645 |
-
|
| 646 |
-
export default AdminPane;
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// settings / AdminPane.tsx β THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 /
|
| 3 |
+
// R3+R4, contract C2).
|
| 4 |
+
//
|
| 5 |
+
// The one surface in this product that looks ACROSS tenants: who our customers
|
| 6 |
+
// are, how many people use each workspace, what they have built in it, whether
|
| 7 |
+
// their data sources are alive, and what the automation fleet costs.
|
| 8 |
+
//
|
| 9 |
+
// SELF-CONTAINED BY CONTRACT (C2). It fetches its own data via
|
| 10 |
+
// `platformAdminApi` and takes ONE prop, so mounting it is a rail entry plus a
|
| 11 |
+
// line in the pane switch β `SettingsModal.tsx` is another session's file this
|
| 12 |
+
// wave and must not have to learn anything about this pane's data.
|
| 13 |
+
//
|
| 14 |
+
// β THE PROP IS TYPED STRUCTURALLY, not as `SessionUser` from `../shell/session`.
|
| 15 |
+
// The mount site passes the shell's session user (a wider object), which
|
| 16 |
+
// satisfies this shape by structural typing β and this pane therefore imports
|
| 17 |
+
// nothing from the shell, which is what the wave's cross-fence rule asks for
|
| 18 |
+
// while `session.ts` is open on another desk.
|
| 19 |
+
//
|
| 20 |
+
// β EVERY NUMBER HERE IS THE SERVER'S, AND EVERY NUMBER DRILLS. Clicking a count
|
| 21 |
+
// opens the rows it was computed from β the same collector, projected twice
|
| 22 |
+
// ([[no-unverifiable-aggregates]]). A count that could not be READ renders as an
|
| 23 |
+
// em dash with the reason, never as a zero: "this customer has no databases" and
|
| 24 |
+
// "we could not look" are different facts and the pane refuses to conflate them.
|
| 25 |
+
//
|
| 26 |
+
// DESIGN: sentence-case micro-labels (R6 β no caps in app chrome), short noun
|
| 27 |
+
// headers, tabular figures right-aligned, hairlines not shadows, tokens only,
|
| 28 |
+
// no emojis (DESIGN.md Β§2βΒ§4).
|
| 29 |
+
// ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
import { useCallback, useEffect, useState } from "react";
|
| 32 |
+
import type {
|
| 33 |
+
AutomationRow,
|
| 34 |
+
ConnectorRow,
|
| 35 |
+
DatabaseRow,
|
| 36 |
+
FleetCost,
|
| 37 |
+
Overview,
|
| 38 |
+
PlatformUser,
|
| 39 |
+
ReleasesPayload,
|
| 40 |
+
} from "./platformAdminApi";
|
| 41 |
+
import {
|
| 42 |
+
getAutomations,
|
| 43 |
+
getAws,
|
| 44 |
+
getReleases,
|
| 45 |
+
getConnectors,
|
| 46 |
+
getDatabases,
|
| 47 |
+
getOverview,
|
| 48 |
+
getUsers,
|
| 49 |
+
} from "./platformAdminApi";
|
| 50 |
+
|
| 51 |
+
// --- formatting -------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
const int = (n: number) => n.toLocaleString();
|
| 54 |
+
|
| 55 |
+
/** A count that may be unknown. `null` is NOT zero β see the api module's header. */
|
| 56 |
+
function Count({ n, why }: { n: number | null | undefined; why?: string }) {
|
| 57 |
+
if (n === null || n === undefined)
|
| 58 |
+
return (
|
| 59 |
+
<span className="padmin-unknown" title={why || "This could not be read just now"}>
|
| 60 |
+
β
|
| 61 |
+
</span>
|
| 62 |
+
);
|
| 63 |
+
return <>{int(n)}</>;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** "3 days ago" for a stamp, "never" for an absent one. Never a fabricated date.
|
| 67 |
+
*
|
| 68 |
+
* β ONLY FOR OFFSET-BEARING STAMPS. `Date.parse` reads a stamp with no zone as
|
| 69 |
+
* BROWSER-LOCAL, so running a container-local timestamp through this reports the
|
| 70 |
+
* viewer's UTC offset as elapsed time β hours of error in a column people scan,
|
| 71 |
+
* and a future-dated stamp that renders as "just now" indefinitely. Everything
|
| 72 |
+
* this pane passes here (`last_login`, `last_active`, `generatedAt`) is written
|
| 73 |
+
* with an explicit offset; the automation engine's `lastRunAt` is NOT, so it is
|
| 74 |
+
* rendered verbatim instead. */
|
| 75 |
+
function ago(stamp: string): string {
|
| 76 |
+
if (!stamp) return "never";
|
| 77 |
+
const t = Date.parse(stamp.includes("T") ? stamp : stamp.replace(" ", "T"));
|
| 78 |
+
if (Number.isNaN(t)) return stamp;
|
| 79 |
+
const mins = Math.floor((Date.now() - t) / 60000);
|
| 80 |
+
if (mins < 2) return "just now";
|
| 81 |
+
if (mins < 60) return `${mins} minutes ago`;
|
| 82 |
+
const hours = Math.floor(mins / 60);
|
| 83 |
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
| 84 |
+
const days = Math.floor(hours / 24);
|
| 85 |
+
if (days < 60) return `${days} day${days === 1 ? "" : "s"} ago`;
|
| 86 |
+
return `${Math.floor(days / 30)} months ago`;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
type DrillKind = "users" | "databases" | "connectors" | "automations";
|
| 90 |
+
|
| 91 |
+
const DRILL_NOUN: Record<DrillKind, string> = {
|
| 92 |
+
users: "People",
|
| 93 |
+
databases: "Databases",
|
| 94 |
+
connectors: "Data sources",
|
| 95 |
+
automations: "Automations",
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
/** The loading shape β a skeleton, not a spinner (DESIGN.md Β§4). */
|
| 99 |
+
function Skeleton({ rows = 3 }: { rows?: number }) {
|
| 100 |
+
return (
|
| 101 |
+
<div className="padmin-skel" aria-hidden="true">
|
| 102 |
+
{Array.from({ length: rows }, (_, i) => (
|
| 103 |
+
<div key={i} className="padmin-skel-row" />
|
| 104 |
+
))}
|
| 105 |
+
</div>
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// --- the pane ---------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
export function AdminPane({ user }: { user: { username: string; name: string; role: string } }) {
|
| 112 |
+
const [ov, setOv] = useState<Overview | null>(null);
|
| 113 |
+
const [err, setErr] = useState("");
|
| 114 |
+
const [fleet, setFleet] = useState<{ rows: AutomationRow[]; cost: FleetCost } | null>(null);
|
| 115 |
+
|
| 116 |
+
const [drill, setDrill] = useState<{ kind: DrillKind; tenant: string } | null>(null);
|
| 117 |
+
const [drillRows, setDrillRows] = useState<unknown[] | null>(null);
|
| 118 |
+
const [drillNote, setDrillNote] = useState("");
|
| 119 |
+
|
| 120 |
+
const [aws, setAws] = useState<{ text: string; note: string; available: boolean } | null>(null);
|
| 121 |
+
// Wave 20 (R6): the releases panel. Loaded with the overview rather than on demand β it is
|
| 122 |
+
// two small Hub reads and the first question an operator opens this pane to answer.
|
| 123 |
+
const [rel, setRel] = useState<ReleasesPayload | null>(null);
|
| 124 |
+
const [awsBusy, setAwsBusy] = useState(false);
|
| 125 |
+
|
| 126 |
+
const load = useCallback(() => {
|
| 127 |
+
setOv(null);
|
| 128 |
+
void getOverview().then((r) => {
|
| 129 |
+
if (r.ok) {
|
| 130 |
+
setOv(r.data);
|
| 131 |
+
setErr("");
|
| 132 |
+
} else setErr(r.message);
|
| 133 |
+
});
|
| 134 |
+
void getAutomations().then((r) => {
|
| 135 |
+
if (r.ok) setFleet({ rows: r.data.automations, cost: r.data.cost });
|
| 136 |
+
});
|
| 137 |
+
void getReleases().then((r) => {
|
| 138 |
+
if (r.ok) setRel(r.data);
|
| 139 |
+
});
|
| 140 |
+
}, []);
|
| 141 |
+
useEffect(load, [load]);
|
| 142 |
+
|
| 143 |
+
const openDrill = useCallback((kind: DrillKind, tenant: string) => {
|
| 144 |
+
setDrill({ kind, tenant });
|
| 145 |
+
setDrillRows(null);
|
| 146 |
+
setDrillNote("");
|
| 147 |
+
const fetcher =
|
| 148 |
+
kind === "users"
|
| 149 |
+
? getUsers
|
| 150 |
+
: kind === "databases"
|
| 151 |
+
? getDatabases
|
| 152 |
+
: kind === "connectors"
|
| 153 |
+
? getConnectors
|
| 154 |
+
: getAutomations;
|
| 155 |
+
void fetcher(tenant || undefined).then((r) => {
|
| 156 |
+
if (!r.ok) {
|
| 157 |
+
setDrillRows([]);
|
| 158 |
+
setDrillNote(r.message);
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
const d = r.data as Record<string, unknown>;
|
| 162 |
+
setDrillRows((d[kind] as unknown[]) ?? []);
|
| 163 |
+
// The server's own honest note travels with the rows rather than being
|
| 164 |
+
// re-invented here: it is the one that knows WHY a tenant is missing.
|
| 165 |
+
const errors = (d.errors as Record<string, string>) ?? {};
|
| 166 |
+
const words = Object.entries(errors).map(([t, e]) => `${t}: ${e}`);
|
| 167 |
+
setDrillNote(
|
| 168 |
+
[typeof d.stampsNote === "string" ? d.stampsNote : "", ...words]
|
| 169 |
+
.filter(Boolean)
|
| 170 |
+
.join(" Β· ")
|
| 171 |
+
);
|
| 172 |
+
});
|
| 173 |
+
}, []);
|
| 174 |
+
|
| 175 |
+
const loadAws = useCallback(() => {
|
| 176 |
+
setAwsBusy(true);
|
| 177 |
+
void getAws(7).then((r) => {
|
| 178 |
+
setAwsBusy(false);
|
| 179 |
+
if (r.ok) setAws(r.data.report);
|
| 180 |
+
else setAws({ text: "", note: r.message, available: false });
|
| 181 |
+
});
|
| 182 |
+
}, []);
|
| 183 |
+
|
| 184 |
+
return (
|
| 185 |
+
<div className="set-pane">
|
| 186 |
+
<h3 className="set-h">Loopable admin</h3>
|
| 187 |
+
<p className="set-help set-pane-intro">
|
| 188 |
+
Every workspace on the platform, signed in as {user.name}. Counts open the rows they were
|
| 189 |
+
computed from; anything that could not be read shows a dash and says why, rather than a
|
| 190 |
+
zero.
|
| 191 |
+
</p>
|
| 192 |
+
{err ? <p className="set-error">{err}</p> : null}
|
| 193 |
+
|
| 194 |
+
{!ov ? (
|
| 195 |
+
<Skeleton rows={4} />
|
| 196 |
+
) : (
|
| 197 |
+
<>
|
| 198 |
+
<div className="padmin-totals">
|
| 199 |
+
<Total label="Workspaces" value={int(ov.totals.tenants)} />
|
| 200 |
+
<Total label="People" value={int(ov.totals.users)} />
|
| 201 |
+
<Total label="Databases" value={int(ov.totals.databases)} />
|
| 202 |
+
<Total label="Records" value={int(ov.totals.rows)} />
|
| 203 |
+
<Total
|
| 204 |
+
label="Automation cost"
|
| 205 |
+
value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "β"}
|
| 206 |
+
hint={fleet?.cost.basis}
|
| 207 |
+
/>
|
| 208 |
+
</div>
|
| 209 |
+
{ov.totals.unknownTenants > 0 ? (
|
| 210 |
+
<p className="set-help padmin-caveat">
|
| 211 |
+
{ov.totals.unknownTenants} workspace
|
| 212 |
+
{ov.totals.unknownTenants === 1 ? "" : "s"} could not be read, so the totals above
|
| 213 |
+
exclude {ov.totals.unknownTenants === 1 ? "it" : "them"}.
|
| 214 |
+
</p>
|
| 215 |
+
) : null}
|
| 216 |
+
|
| 217 |
+
<div className="padmin-tablewrap">
|
| 218 |
+
<table className="padmin-table">
|
| 219 |
+
<thead>
|
| 220 |
+
<tr>
|
| 221 |
+
<th>Workspace</th>
|
| 222 |
+
<th>Storage</th>
|
| 223 |
+
<th className="padmin-num">People</th>
|
| 224 |
+
<th className="padmin-num">Databases</th>
|
| 225 |
+
<th className="padmin-num">Records</th>
|
| 226 |
+
<th className="padmin-num">Sources</th>
|
| 227 |
+
<th className="padmin-num">Automations</th>
|
| 228 |
+
</tr>
|
| 229 |
+
</thead>
|
| 230 |
+
<tbody>
|
| 231 |
+
{ov.tenants.map((t) => (
|
| 232 |
+
<tr key={t.slug}>
|
| 233 |
+
<td>
|
| 234 |
+
<span className="padmin-name">{t.name}</span>
|
| 235 |
+
<span className="padmin-meta">
|
| 236 |
+
{t.slug}
|
| 237 |
+
{t.status !== "active" ? ` Β· ${t.status}` : ""}
|
| 238 |
+
{t.errors && t.errors.length ? ` Β· ${t.errors[0]}` : ""}
|
| 239 |
+
</span>
|
| 240 |
+
</td>
|
| 241 |
+
<td>
|
| 242 |
+
<span className="padmin-meta">
|
| 243 |
+
{t.storeRepo && t.storeRepo.includes("/")
|
| 244 |
+
? "own repository"
|
| 245 |
+
: t.storePrefix
|
| 246 |
+
? "shared repository"
|
| 247 |
+
: "tenant #0 repository"}
|
| 248 |
+
{t.keychainLocked ? " Β· keychain locked" : ""}
|
| 249 |
+
</span>
|
| 250 |
+
</td>
|
| 251 |
+
<DrillCell
|
| 252 |
+
n={t.users}
|
| 253 |
+
onOpen={() => openDrill("users", t.slug)}
|
| 254 |
+
title={`${t.admins} administrator${t.admins === 1 ? "" : "s"}`}
|
| 255 |
+
/>
|
| 256 |
+
<DrillCell
|
| 257 |
+
n={t.databases}
|
| 258 |
+
onOpen={() => openDrill("databases", t.slug)}
|
| 259 |
+
why={t.errors?.[0]}
|
| 260 |
+
/>
|
| 261 |
+
<DrillCell
|
| 262 |
+
n={t.rows}
|
| 263 |
+
onOpen={() => openDrill("databases", t.slug)}
|
| 264 |
+
why={t.errors?.[0]}
|
| 265 |
+
/>
|
| 266 |
+
<DrillCell
|
| 267 |
+
n={t.connectors}
|
| 268 |
+
onOpen={() => openDrill("connectors", t.slug)}
|
| 269 |
+
why={t.errors?.[0]}
|
| 270 |
+
title={
|
| 271 |
+
t.connectorsPaused ? `${t.connectorsPaused} paused` : "none paused"
|
| 272 |
+
}
|
| 273 |
+
/>
|
| 274 |
+
<DrillCell
|
| 275 |
+
n={t.automations}
|
| 276 |
+
onOpen={() => openDrill("automations", t.slug)}
|
| 277 |
+
why={t.errors?.[0]}
|
| 278 |
+
title={
|
| 279 |
+
t.automationsEnabled != null
|
| 280 |
+
? `${t.automationsEnabled} scheduled`
|
| 281 |
+
: undefined
|
| 282 |
+
}
|
| 283 |
+
/>
|
| 284 |
+
</tr>
|
| 285 |
+
))}
|
| 286 |
+
</tbody>
|
| 287 |
+
</table>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
<p className="set-help padmin-caveat">
|
| 291 |
+
Read {ago(ov.generatedAt)} in {ov.tookMs} ms.{" "}
|
| 292 |
+
{ov.totals.orphanUsers > 0
|
| 293 |
+
? `${ov.totals.orphanUsers} account${
|
| 294 |
+
ov.totals.orphanUsers === 1 ? "" : "s"
|
| 295 |
+
} belong to a workspace this deployment no longer knows. `
|
| 296 |
+
: ""}
|
| 297 |
+
<button type="button" className="padmin-link" onClick={load}>
|
| 298 |
+
Refresh
|
| 299 |
+
</button>
|
| 300 |
+
</p>
|
| 301 |
+
|
| 302 |
+
{drill ? (
|
| 303 |
+
<section className="padmin-drill">
|
| 304 |
+
<div className="padmin-drill-head">
|
| 305 |
+
<h4 className="padmin-h4">
|
| 306 |
+
{DRILL_NOUN[drill.kind]}
|
| 307 |
+
{drill.tenant ? `: ${drill.tenant}` : ""}
|
| 308 |
+
</h4>
|
| 309 |
+
<button type="button" className="padmin-link" onClick={() => setDrill(null)}>
|
| 310 |
+
Close
|
| 311 |
+
</button>
|
| 312 |
+
</div>
|
| 313 |
+
{drillRows === null ? (
|
| 314 |
+
<Skeleton rows={2} />
|
| 315 |
+
) : drillRows.length === 0 ? (
|
| 316 |
+
<p className="set-help">Nothing here yet.</p>
|
| 317 |
+
) : (
|
| 318 |
+
<DrillTable kind={drill.kind} rows={drillRows} />
|
| 319 |
+
)}
|
| 320 |
+
{drillNote ? <p className="set-help padmin-caveat">{drillNote}</p> : null}
|
| 321 |
+
</section>
|
| 322 |
+
) : null}
|
| 323 |
+
|
| 324 |
+
{fleet ? (
|
| 325 |
+
<section className="padmin-block">
|
| 326 |
+
{/* Named for what it SHOWS. The fleet's rows live one level down,
|
| 327 |
+
behind the Automations count β this section is the cost and the
|
| 328 |
+
reasoning behind it, so calling it "fleet" would promise a
|
| 329 |
+
table that is deliberately not here. */}
|
| 330 |
+
<h4 className="padmin-h4">Automation cost</h4>
|
| 331 |
+
<p className="set-help">
|
| 332 |
+
{fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
|
| 333 |
+
{fleet.cost.fleetRunsPerDay
|
| 334 |
+
? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
|
| 335 |
+
: ""}
|
| 336 |
+
{fleet.cost.unknownCadence
|
| 337 |
+
? ` (${fleet.cost.unknownCadence} on a custom cadence, not counted)`
|
| 338 |
+
: ""}
|
| 339 |
+
. {fleet.cost.basis}
|
| 340 |
+
</p>
|
| 341 |
+
</section>
|
| 342 |
+
) : null}
|
| 343 |
+
|
| 344 |
+
{/* ββ Wave 20 (owner item 12 / R6): what is running where ββββββββββββββββββ
|
| 345 |
+
READ-ONLY BY RULING. The operator sees both environments and every cut
|
| 346 |
+
release; moving one is a CLI command, printed below rather than wired to
|
| 347 |
+
a button, so no browser session can rewrite production. */}
|
| 348 |
+
<section className="padmin-block">
|
| 349 |
+
<h4 className="padmin-h4">Releases</h4>
|
| 350 |
+
{!rel ? (
|
| 351 |
+
<Skeleton rows={2} />
|
| 352 |
+
) : (
|
| 353 |
+
<>
|
| 354 |
+
<table className="padmin-table">
|
| 355 |
+
<thead>
|
| 356 |
+
<tr>
|
| 357 |
+
<th>Environment</th>
|
| 358 |
+
<th>Version</th>
|
| 359 |
+
<th>Stage</th>
|
| 360 |
+
<th>Space</th>
|
| 361 |
+
</tr>
|
| 362 |
+
</thead>
|
| 363 |
+
<tbody>
|
| 364 |
+
{rel.environments.map((e) => (
|
| 365 |
+
<tr key={e.env}>
|
| 366 |
+
<td>{e.env === "live" ? "Live (pinned)" : "Staging (follows the tree)"}</td>
|
| 367 |
+
{/* β An unreadable version is a NOTE, never a blank. A blank cell in a
|
| 368 |
+
column headed "Version" reads as "nothing is deployed", which about a
|
| 369 |
+
production environment is the worst available way to be wrong. */}
|
| 370 |
+
<td>{e.version || <span className="set-help">{e.note || "unknown"}</span>}</td>
|
| 371 |
+
<td>{e.stage || "β"}</td>
|
| 372 |
+
{/* The Space ID as TEXT, not a link: the server deliberately builds no
|
| 373 |
+
URL (portability C1 β a host literal in runtime code hard-codes the
|
| 374 |
+
current host into a process designed to move). */}
|
| 375 |
+
<td>{e.space}</td>
|
| 376 |
+
</tr>
|
| 377 |
+
))}
|
| 378 |
+
</tbody>
|
| 379 |
+
</table>
|
| 380 |
+
{rel.releases.length ? (
|
| 381 |
+
<table className="padmin-table">
|
| 382 |
+
<thead>
|
| 383 |
+
<tr>
|
| 384 |
+
<th>Release</th>
|
| 385 |
+
<th>Commit</th>
|
| 386 |
+
<th>Cut</th>
|
| 387 |
+
<th>What shipped</th>
|
| 388 |
+
</tr>
|
| 389 |
+
</thead>
|
| 390 |
+
<tbody>
|
| 391 |
+
{rel.releases.map((r) => (
|
| 392 |
+
<tr key={r.version}>
|
| 393 |
+
<td>{r.version}</td>
|
| 394 |
+
<td>{r.sha}</td>
|
| 395 |
+
<td>{r.date}</td>
|
| 396 |
+
<td>{r.subject}</td>
|
| 397 |
+
</tr>
|
| 398 |
+
))}
|
| 399 |
+
</tbody>
|
| 400 |
+
</table>
|
| 401 |
+
) : (
|
| 402 |
+
<p className="set-help">
|
| 403 |
+
No tagged releases were readable from either Space.
|
| 404 |
+
</p>
|
| 405 |
+
)}
|
| 406 |
+
<p className="set-help">
|
| 407 |
+
To move Live to another version, or roll it back, run:{" "}
|
| 408 |
+
<code>{rel.promote}</code>
|
| 409 |
+
</p>
|
| 410 |
+
</>
|
| 411 |
+
)}
|
| 412 |
+
</section>
|
| 413 |
+
|
| 414 |
+
<section className="padmin-block">
|
| 415 |
+
<h4 className="padmin-h4">AWS cron</h4>
|
| 416 |
+
{!aws ? (
|
| 417 |
+
<p className="set-help">
|
| 418 |
+
The external tick that wakes this app on schedule. Reading its usage runs a live
|
| 419 |
+
CloudWatch query and takes a few seconds.{" "}
|
| 420 |
+
<button
|
| 421 |
+
type="button"
|
| 422 |
+
className="padmin-link"
|
| 423 |
+
onClick={loadAws}
|
| 424 |
+
disabled={awsBusy}
|
| 425 |
+
>
|
| 426 |
+
{awsBusy ? "Readingβ¦" : "Check usage"}
|
| 427 |
+
</button>
|
| 428 |
+
</p>
|
| 429 |
+
) : aws.available ? (
|
| 430 |
+
<pre className="padmin-pre">{aws.text}</pre>
|
| 431 |
+
) : (
|
| 432 |
+
<p className="set-help">{aws.note}</p>
|
| 433 |
+
)}
|
| 434 |
+
</section>
|
| 435 |
+
</>
|
| 436 |
+
)}
|
| 437 |
+
</div>
|
| 438 |
+
);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function Total({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
| 442 |
+
// A tooltip nobody can see is a tooltip nobody reads: the label carries a
|
| 443 |
+
// dotted underline exactly when there is something behind it.
|
| 444 |
+
return (
|
| 445 |
+
<div className={"padmin-total" + (hint ? " has-hint" : "")} title={hint || undefined}>
|
| 446 |
+
<span className="padmin-total-label">{label}</span>
|
| 447 |
+
<span className="padmin-total-value">{value}</span>
|
| 448 |
+
</div>
|
| 449 |
+
);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/** A numeric cell that opens its own rows. Unknown counts are not clickable β
|
| 453 |
+
* there is nothing to drill INTO when the read failed, and a button that opens
|
| 454 |
+
* an empty table would read as "none". */
|
| 455 |
+
function DrillCell({
|
| 456 |
+
n,
|
| 457 |
+
onOpen,
|
| 458 |
+
why,
|
| 459 |
+
title,
|
| 460 |
+
}: {
|
| 461 |
+
n: number | null | undefined;
|
| 462 |
+
onOpen: () => void;
|
| 463 |
+
why?: string;
|
| 464 |
+
title?: string;
|
| 465 |
+
}) {
|
| 466 |
+
if (n === null || n === undefined)
|
| 467 |
+
return (
|
| 468 |
+
<td className="padmin-num">
|
| 469 |
+
<Count n={n} why={why} />
|
| 470 |
+
</td>
|
| 471 |
+
);
|
| 472 |
+
return (
|
| 473 |
+
<td className="padmin-num">
|
| 474 |
+
<button type="button" className="padmin-drill-btn" onClick={onOpen} title={title}>
|
| 475 |
+
{int(n)}
|
| 476 |
+
</button>
|
| 477 |
+
</td>
|
| 478 |
+
);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function DrillTable({ kind, rows }: { kind: DrillKind; rows: unknown[] }) {
|
| 482 |
+
if (kind === "users") {
|
| 483 |
+
const rs = rows as PlatformUser[];
|
| 484 |
+
return (
|
| 485 |
+
<div className="padmin-tablewrap">
|
| 486 |
+
<table className="padmin-table">
|
| 487 |
+
<thead>
|
| 488 |
+
<tr>
|
| 489 |
+
<th>Person</th>
|
| 490 |
+
<th>Workspace</th>
|
| 491 |
+
<th>Role</th>
|
| 492 |
+
<th>Last sign-in</th>
|
| 493 |
+
<th>Last active</th>
|
| 494 |
+
</tr>
|
| 495 |
+
</thead>
|
| 496 |
+
<tbody>
|
| 497 |
+
{rs.map((u) => (
|
| 498 |
+
<tr key={`${u.tenant}/${u.username}`}>
|
| 499 |
+
<td>
|
| 500 |
+
<span className="padmin-name">{u.name}</span>
|
| 501 |
+
<span className="padmin-meta">
|
| 502 |
+
{u.username}
|
| 503 |
+
{u.email ? ` Β· ${u.email}` : ""}
|
| 504 |
+
{u.active ? "" : " Β· deactivated"}
|
| 505 |
+
</span>
|
| 506 |
+
</td>
|
| 507 |
+
<td>{u.tenant}</td>
|
| 508 |
+
<td>
|
| 509 |
+
{u.role}
|
| 510 |
+
{u.platformAdmin ? " Β· platform" : ""}
|
| 511 |
+
</td>
|
| 512 |
+
<td>{ago(u.lastLogin)}</td>
|
| 513 |
+
<td>{ago(u.lastActive)}</td>
|
| 514 |
+
</tr>
|
| 515 |
+
))}
|
| 516 |
+
</tbody>
|
| 517 |
+
</table>
|
| 518 |
+
</div>
|
| 519 |
+
);
|
| 520 |
+
}
|
| 521 |
+
if (kind === "databases") {
|
| 522 |
+
const rs = rows as DatabaseRow[];
|
| 523 |
+
return (
|
| 524 |
+
<div className="padmin-tablewrap">
|
| 525 |
+
<table className="padmin-table">
|
| 526 |
+
<thead>
|
| 527 |
+
<tr>
|
| 528 |
+
<th>Database</th>
|
| 529 |
+
<th>Workspace</th>
|
| 530 |
+
<th>Built from</th>
|
| 531 |
+
<th className="padmin-num">Fields</th>
|
| 532 |
+
<th className="padmin-num">Records</th>
|
| 533 |
+
</tr>
|
| 534 |
+
</thead>
|
| 535 |
+
<tbody>
|
| 536 |
+
{rs.map((d) => (
|
| 537 |
+
<tr key={`${d.tenant}/${d.key}`}>
|
| 538 |
+
<td>
|
| 539 |
+
<span className="padmin-name">{d.label}</span>
|
| 540 |
+
<span className="padmin-meta">
|
| 541 |
+
{d.key}
|
| 542 |
+
{d.createdBy ? ` Β· ${d.createdBy}` : ""}
|
| 543 |
+
</span>
|
| 544 |
+
</td>
|
| 545 |
+
<td>{d.tenant}</td>
|
| 546 |
+
<td>{d.source}</td>
|
| 547 |
+
<td className="padmin-num">{int(d.fields)}</td>
|
| 548 |
+
<td className="padmin-num">{int(d.rowCount)}</td>
|
| 549 |
+
</tr>
|
| 550 |
+
))}
|
| 551 |
+
</tbody>
|
| 552 |
+
</table>
|
| 553 |
+
</div>
|
| 554 |
+
);
|
| 555 |
+
}
|
| 556 |
+
if (kind === "connectors") {
|
| 557 |
+
const rs = rows as ConnectorRow[];
|
| 558 |
+
return (
|
| 559 |
+
<div className="padmin-tablewrap">
|
| 560 |
+
<table className="padmin-table">
|
| 561 |
+
<thead>
|
| 562 |
+
<tr>
|
| 563 |
+
<th>Source</th>
|
| 564 |
+
<th>Workspace</th>
|
| 565 |
+
<th>Kind</th>
|
| 566 |
+
<th>Status</th>
|
| 567 |
+
</tr>
|
| 568 |
+
</thead>
|
| 569 |
+
<tbody>
|
| 570 |
+
{rs.map((c) => (
|
| 571 |
+
<tr key={`${c.tenant}/${c.key}`}>
|
| 572 |
+
<td>
|
| 573 |
+
<span className="padmin-name">{c.label}</span>
|
| 574 |
+
<span className="padmin-meta">
|
| 575 |
+
{c.source === "env" ? "environment credentials" : "keychain"}
|
| 576 |
+
</span>
|
| 577 |
+
</td>
|
| 578 |
+
<td>{c.tenant}</td>
|
| 579 |
+
<td>{c.type}</td>
|
| 580 |
+
<td>
|
| 581 |
+
<span
|
| 582 |
+
className={
|
| 583 |
+
"padmin-dot " +
|
| 584 |
+
(c.paused ? "is-paused" : c.active ? "is-live" : "is-idle")
|
| 585 |
+
}
|
| 586 |
+
/>
|
| 587 |
+
{c.paused ? "paused" : c.active ? "serving" : "stored"}
|
| 588 |
+
</td>
|
| 589 |
+
</tr>
|
| 590 |
+
))}
|
| 591 |
+
</tbody>
|
| 592 |
+
</table>
|
| 593 |
+
</div>
|
| 594 |
+
);
|
| 595 |
+
}
|
| 596 |
+
const rs = rows as AutomationRow[];
|
| 597 |
+
return (
|
| 598 |
+
<div className="padmin-tablewrap">
|
| 599 |
+
<table className="padmin-table">
|
| 600 |
+
<thead>
|
| 601 |
+
<tr>
|
| 602 |
+
<th>Automation</th>
|
| 603 |
+
<th>Workspace</th>
|
| 604 |
+
<th>Schedule</th>
|
| 605 |
+
<th className="padmin-num">Runs a day</th>
|
| 606 |
+
<th>Last run</th>
|
| 607 |
+
</tr>
|
| 608 |
+
</thead>
|
| 609 |
+
<tbody>
|
| 610 |
+
{rs.map((a) => (
|
| 611 |
+
<tr key={`${a.tenant}/${a.id}`}>
|
| 612 |
+
<td>
|
| 613 |
+
<span className="padmin-name">{a.name}</span>
|
| 614 |
+
<span className="padmin-meta">
|
| 615 |
+
{a.kind}
|
| 616 |
+
{a.failedRetained
|
| 617 |
+
? ` Β· ${a.failedRetained} of the last ${a.runsRetained} runs failed`
|
| 618 |
+
: ""}
|
| 619 |
+
</span>
|
| 620 |
+
</td>
|
| 621 |
+
<td>{a.tenant}</td>
|
| 622 |
+
<td>{a.enabled ? a.cron || "scheduled" : "paused"}</td>
|
| 623 |
+
<td className="padmin-num">
|
| 624 |
+
{a.enabled ? (a.runsPerDay === null ? "custom" : a.runsPerDay) : "β"}
|
| 625 |
+
</td>
|
| 626 |
+
<td>
|
| 627 |
+
<span
|
| 628 |
+
className={
|
| 629 |
+
"padmin-dot " +
|
| 630 |
+
(a.state === "error" ? "is-error" : a.state === "ok" ? "is-live" : "is-idle")
|
| 631 |
+
}
|
| 632 |
+
/>
|
| 633 |
+
{/* Verbatim, not relative: the engine writes this stamp with no
|
| 634 |
+
zone (`automation_engine._iso()` is naive local-to-container),
|
| 635 |
+
so "x hours ago" would be wrong by the viewer's offset. */}
|
| 636 |
+
{a.lastRunAt ? a.lastRunAt.replace("T", " ") : "never"}
|
| 637 |
+
</td>
|
| 638 |
+
</tr>
|
| 639 |
+
))}
|
| 640 |
+
</tbody>
|
| 641 |
+
</table>
|
| 642 |
+
</div>
|
| 643 |
+
);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
export default AdminPane;
|
web/src/settings/PermsEditor.tsx
CHANGED
|
@@ -393,7 +393,7 @@ export function PermsEditor({
|
|
| 393 |
// to believe an account is restricted when it is not.
|
| 394 |
if (p.isAdmin) {
|
| 395 |
notes.push(
|
| 396 |
-
"is an administrator
|
| 397 |
);
|
| 398 |
}
|
| 399 |
// β A COPY MIGRATES THE TARGET. An un-migrated record runs under the
|
|
@@ -404,7 +404,7 @@ export function PermsEditor({
|
|
| 404 |
// exotic one.
|
| 405 |
if (!p.migrated) {
|
| 406 |
notes.push(
|
| 407 |
-
"still uses the previous access model
|
| 408 |
);
|
| 409 |
}
|
| 410 |
// The same disclosure this page already makes for the account it has
|
|
@@ -412,7 +412,7 @@ export function PermsEditor({
|
|
| 412 |
// does it to somebody the admin never opened.
|
| 413 |
if (p.orphanModules.length) {
|
| 414 |
notes.push(
|
| 415 |
-
`has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers
|
| 416 |
);
|
| 417 |
}
|
| 418 |
// β DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
|
|
@@ -489,7 +489,7 @@ export function PermsEditor({
|
|
| 489 |
if (dropped.size) {
|
| 490 |
const list = nameList([...dropped].sort());
|
| 491 |
problems.push(
|
| 492 |
-
`${list} could not be copied
|
| 493 |
);
|
| 494 |
}
|
| 495 |
if (problems.length) setError(problems.join(" "));
|
|
@@ -687,7 +687,7 @@ export function PermsEditor({
|
|
| 687 |
disabled={!canCopy || busy}
|
| 688 |
title={
|
| 689 |
dirty
|
| 690 |
-
? "Save this account's access first
|
| 691 |
: undefined
|
| 692 |
}
|
| 693 |
onClick={() => openCopy({ kind: "all" })}
|
|
@@ -709,7 +709,7 @@ export function PermsEditor({
|
|
| 709 |
reader who thought to hover. */}
|
| 710 |
{dirty && copyTargets?.length ? (
|
| 711 |
<p className="set-help set-copy-why">
|
| 712 |
-
Copying is unavailable while there are unsaved changes
|
| 713 |
what is stored, not what is on screen.
|
| 714 |
</p>
|
| 715 |
) : null}
|
|
|
|
| 393 |
// to believe an account is restricted when it is not.
|
| 394 |
if (p.isAdmin) {
|
| 395 |
notes.push(
|
| 396 |
+
"is an administrator, so the rules will be stored but stay inert until the account is changed to Member"
|
| 397 |
);
|
| 398 |
}
|
| 399 |
// β A COPY MIGRATES THE TARGET. An un-migrated record runs under the
|
|
|
|
| 404 |
// exotic one.
|
| 405 |
if (!p.migrated) {
|
| 406 |
notes.push(
|
| 407 |
+
"still uses the previous access model, and this write moves it to the per-module model"
|
| 408 |
);
|
| 409 |
}
|
| 410 |
// The same disclosure this page already makes for the account it has
|
|
|
|
| 412 |
// does it to somebody the admin never opened.
|
| 413 |
if (p.orphanModules.length) {
|
| 414 |
notes.push(
|
| 415 |
+
`has rules for ${p.orphanModules.join(", ")}, which this deployment no longer offers, so those are removed`
|
| 416 |
);
|
| 417 |
}
|
| 418 |
// β DISCLOSED BEFORE THE WRITE, not only after it. `runCopy` also reports
|
|
|
|
| 489 |
if (dropped.size) {
|
| 490 |
const list = nameList([...dropped].sort());
|
| 491 |
problems.push(
|
| 492 |
+
`${list} could not be copied. The accounts you picked do not offer ${dropped.size === 1 ? "it" : "them"}.`
|
| 493 |
);
|
| 494 |
}
|
| 495 |
if (problems.length) setError(problems.join(" "));
|
|
|
|
| 687 |
disabled={!canCopy || busy}
|
| 688 |
title={
|
| 689 |
dirty
|
| 690 |
+
? "Save this account's access first. A copy sends what is stored, not what is on screen."
|
| 691 |
: undefined
|
| 692 |
}
|
| 693 |
onClick={() => openCopy({ kind: "all" })}
|
|
|
|
| 709 |
reader who thought to hover. */}
|
| 710 |
{dirty && copyTargets?.length ? (
|
| 711 |
<p className="set-help set-copy-why">
|
| 712 |
+
Copying is unavailable while there are unsaved changes. A copy sends
|
| 713 |
what is stored, not what is on screen.
|
| 714 |
</p>
|
| 715 |
) : null}
|
web/src/settings/SettingsModal.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/settings/permsModel.ts
CHANGED
|
@@ -53,10 +53,22 @@ export type SettingsSection =
|
|
| 53 |
// Wave 18 (C7): the tenant's credential store and its data-source status board.
|
| 54 |
| "keychains"
|
| 55 |
| "connectors"
|
| 56 |
-
/*
|
| 57 |
-
*
|
| 58 |
-
*
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
/**
|
| 61 |
* Wave 19 (R3 / contract C2): the Loopable admin plane β the cross-TENANT
|
| 62 |
* console, visible only to a `platform_admin` account.
|
|
@@ -72,6 +84,21 @@ export type SettingsSection =
|
|
| 72 |
*/
|
| 73 |
| "padmin";
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
/**
|
| 76 |
* A non-admin never LANDS on the users pane β and therefore never reaches the
|
| 77 |
* permission editor, which lives inside it.
|
|
@@ -84,8 +111,22 @@ export type SettingsSection =
|
|
| 84 |
* inline ternary cannot have a negative control, and "the pane a non-admin gets
|
| 85 |
* bounced out of" is exactly the kind of rule that gets refactored away by
|
| 86 |
* someone who does not know it is load-bearing.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
*/
|
| 88 |
-
export function reachableSection(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
// ββ WAVE 32 Β· R4 β `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason.
|
| 90 |
//
|
| 91 |
// Wave 18 (C7) put them here because "every control inside them would 403 a member" β which was
|
|
@@ -107,11 +148,10 @@ export function reachableSection(section: SettingsSection, admin: boolean): Sett
|
|
| 107 |
// that the routes refuse β this list is what stops the EMPTY FRAME, and a new
|
| 108 |
// admin room added to the rail without a line here is the defect this
|
| 109 |
// function's header describes.
|
| 110 |
-
section === "agents"
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
return adminOnly && !admin ? "account" : section;
|
| 115 |
}
|
| 116 |
|
| 117 |
// --- the wire (C-PERM) ------------------------------------------------------
|
|
|
|
| 53 |
// Wave 18 (C7): the tenant's credential store and its data-source status board.
|
| 54 |
| "keychains"
|
| 55 |
| "connectors"
|
| 56 |
+
/* ββ `"statements"` LEFT THIS UNION (wave 35 Β· T38, owner item 14 / ruling R10).
|
| 57 |
+
*
|
| 58 |
+
* The statement sender is an AGENT now: `Agents -> Monthly statements`, where a scheduled run
|
| 59 |
+
* ASSEMBLES a batch and parks it, and a person releases it with a click. It was here since
|
| 60 |
+
* EXIT-6, ported off `app.py`.
|
| 61 |
+
*
|
| 62 |
+
* β THE SEND DOOR DID NOT MOVE AND DID NOT WEAKEN. `routes_statements` still holds it, still
|
| 63 |
+
* behind `admin_gate` + the Royal-Imports tenant gate, still calling
|
| 64 |
+
* `collections_send.queue_statement` whose SAFE_MODE allow-list lives in the DATA LAYER. What
|
| 65 |
+
* left is one of two front doors onto it, not a capability.
|
| 66 |
+
*
|
| 67 |
+
* β AND A STORED PREFERENCE NAMING IT DEGRADES RATHER THAN BREAKING β see `RETIRED_SECTIONS`
|
| 68 |
+
* below. Dropping a union member is a TYPE change; the values people have already saved are
|
| 69 |
+
* data, and refusing one would strand somebody on a blank pane forever (D-65's lesson: a
|
| 70 |
+
* refused stored key locks the door you need in order to fix it).
|
| 71 |
+
*/
|
| 72 |
/**
|
| 73 |
* Wave 19 (R3 / contract C2): the Loopable admin plane β the cross-TENANT
|
| 74 |
* console, visible only to a `platform_admin` account.
|
|
|
|
| 84 |
*/
|
| 85 |
| "padmin";
|
| 86 |
|
| 87 |
+
/**
|
| 88 |
+
* ββ WAVE 35 Β· T38 β SECTIONS THAT ONCE EXISTED AND NO LONGER DO.
|
| 89 |
+
*
|
| 90 |
+
* β A LIST RATHER THAN A DELETION, because a stored value outlives the type that described it.
|
| 91 |
+
* `SettingsSection` is compile-time; the section somebody last had open is DATA, and a person who
|
| 92 |
+
* was in Statements when this shipped must land somewhere real. Without this they would restore
|
| 93 |
+
* into a section no branch renders β a blank modal, which reads as a broken product rather than as
|
| 94 |
+
* a room that moved.
|
| 95 |
+
*
|
| 96 |
+
* β THE ENTRY STAYS EVEN AFTER NOBODY COULD PLAUSIBLY HOLD IT. It costs one string and it is the
|
| 97 |
+
* only record, in code, that the value was ever legal β deleting it is how the next reader
|
| 98 |
+
* "cleans up" a degrade path and re-opens the blank pane.
|
| 99 |
+
*/
|
| 100 |
+
export const RETIRED_SECTIONS: readonly string[] = ["statements"];
|
| 101 |
+
|
| 102 |
/**
|
| 103 |
* A non-admin never LANDS on the users pane β and therefore never reaches the
|
| 104 |
* permission editor, which lives inside it.
|
|
|
|
| 111 |
* inline ternary cannot have a negative control, and "the pane a non-admin gets
|
| 112 |
* bounced out of" is exactly the kind of rule that gets refactored away by
|
| 113 |
* someone who does not know it is load-bearing.
|
| 114 |
+
*
|
| 115 |
+
* β WAVE 35 Β· T38 β it is ALSO where a RETIRED section is degraded, and the two rules live in one
|
| 116 |
+
* function on purpose: both answer "this section is not reachable, so where does this person
|
| 117 |
+
* actually land", and splitting them would give the caller two chances to forget one.
|
| 118 |
*/
|
| 119 |
+
export function reachableSection(
|
| 120 |
+
/* β WIDER THAN `SettingsSection` ON PURPOSE. The caller passes a value that may have been
|
| 121 |
+
STORED under an older build, so typing this parameter to the current union would make the
|
| 122 |
+
retired case unrepresentable β and therefore unhandleable β at exactly the callsite whose job
|
| 123 |
+
is to handle it. */
|
| 124 |
+
section: SettingsSection | string,
|
| 125 |
+
admin: boolean,
|
| 126 |
+
): SettingsSection {
|
| 127 |
+
// β FIRST, before the admin rule: a retired section has no admin question to answer, and
|
| 128 |
+
// `adminOnly` below would read `false` for it and hand back a section nothing renders.
|
| 129 |
+
if (RETIRED_SECTIONS.includes(String(section))) return "account";
|
| 130 |
// ββ WAVE 32 Β· R4 β `keychains` AND `connectors` LEFT THIS LIST, and the ruling is the reason.
|
| 131 |
//
|
| 132 |
// Wave 18 (C7) put them here because "every control inside them would 403 a member" β which was
|
|
|
|
| 148 |
// that the routes refuse β this list is what stops the EMPTY FRAME, and a new
|
| 149 |
// admin room added to the rail without a line here is the defect this
|
| 150 |
// function's header describes.
|
| 151 |
+
section === "agents";
|
| 152 |
+
// β The cast is safe BECAUSE of the retired-section guard at the top: every value reaching here
|
| 153 |
+
// is either a current member of the union or has already been degraded to "account".
|
| 154 |
+
return adminOnly && !admin ? "account" : (section as SettingsSection);
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
// --- the wire (C-PERM) ------------------------------------------------------
|
web/src/shell/NavExtras.tsx
CHANGED
|
@@ -1,995 +1,986 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// shell/NavExtras.tsx β C-SCHEMA (wave 2026-08-02): the database list's
|
| 3 |
-
// three-dots menu, folder heads, and the schema drawer.
|
| 4 |
-
//
|
| 5 |
-
// Kept out of Shell.tsx so the shell's frame stays readable: Shell owns the
|
| 6 |
-
// STATE (prefs, open folders, which schema is open) and the save round-trip;
|
| 7 |
-
// these components own only their own popover/drawer chrome. Menus are
|
| 8 |
-
// position:fixed against the trigger β the rail scrolls and clips, so an
|
| 9 |
-
// absolutely-positioned child could never escape it (the same lesson as the
|
| 10 |
-
// collapsed-rail tooltip).
|
| 11 |
-
// ---------------------------------------------------------------------------
|
| 12 |
-
|
| 13 |
-
import { useEffect, useRef, useState } from "react";
|
| 14 |
-
import type { ReactNode } from "react";
|
| 15 |
-
import { fetchSchema
|
| 16 |
-
import type { NavFolder,
|
| 17 |
-
// β WAVE 19 R8 / C1 β IMPORTED, NEVER REDEFINED, and the contract says so in as
|
| 18 |
-
// many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
|
| 19 |
-
// shapes, 5 tones); B imports, never redefines"). A second copy of this
|
| 20 |
-
// vocabulary in the shell is a whitelist that can drift by one member from the
|
| 21 |
-
// one the host validates against β and a tone the server does not recognise
|
| 22 |
-
// degrades to the default, so the user's choice disappears on reload with
|
| 23 |
-
// nothing on screen to say why. These are READ-ONLY imports across the session
|
| 24 |
-
// fence: `customer-grid/**` is another session's tree this wave (HARD RULE 3).
|
| 25 |
-
import { FolderMark } from "../customer-grid/icons";
|
| 26 |
-
import { FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS } from "../customer-grid/iconShapes";
|
| 27 |
-
import {
|
| 28 |
-
DEFAULT_FOLDER_SHAPE,
|
| 29 |
-
DEFAULT_FOLDER_TONE,
|
| 30 |
-
FOLDER_SHAPES,
|
| 31 |
-
FOLDER_TONES,
|
| 32 |
-
} from "../customer-grid/types";
|
| 33 |
-
import type { FolderIcon } from "../customer-grid/types";
|
| 34 |
-
import "./navExtras.css";
|
| 35 |
-
|
| 36 |
-
/**
|
| 37 |
-
* WAVE 21 item 12 (ruling R11) β HORIZONTAL, and it is the same three dots turned
|
| 38 |
-
* 90Β°, not a different mark.
|
| 39 |
-
*
|
| 40 |
-
* The rail and the views list are read as one surface, and they were drawing the
|
| 41 |
-
* "there is a menu here" affordance two different ways: this glyph stacked its
|
| 42 |
-
* circles (cx fixed, cy walking) while `ViewSidebar`'s row buttons have always
|
| 43 |
-
* rendered three MIDDLE DOTs on the baseline. Same promise, two pictures β the
|
| 44 |
-
* thing R11 names. Only the axis moves: radius, viewBox and the 14px box in
|
| 45 |
-
* `navExtras.css` are untouched, so the hit target and the optical weight are
|
| 46 |
-
* exactly what shipped.
|
| 47 |
-
*/
|
| 48 |
-
function DotsIcon() {
|
| 49 |
-
return (
|
| 50 |
-
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-dots-icon">
|
| 51 |
-
<circle cx="3.2" cy="8" r="1.35" />
|
| 52 |
-
<circle cx="8" cy="8" r="1.35" />
|
| 53 |
-
<circle cx="12.8" cy="8" r="1.35" />
|
| 54 |
-
</svg>
|
| 55 |
-
);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
function FolderIcon() {
|
| 59 |
-
return (
|
| 60 |
-
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-nav-icon">
|
| 61 |
-
<path d="M2.2 4.4c0-.66.54-1.2 1.2-1.2h3l1.4 1.6h4.8c.66 0 1.2.54 1.2 1.2v6c0 .66-.54 1.2-1.2 1.2H3.4c-.66 0-1.2-.54-1.2-1.2z" />
|
| 62 |
-
</svg>
|
| 63 |
-
);
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
/** A fixed-position popover anchored to a trigger rect, closed on outside
|
| 67 |
-
* click or Escape. Small on purpose β the shell does not import the grid's
|
| 68 |
-
* Popover, so the two chrome trees stay independently deletable. */
|
| 69 |
-
function useMenu() {
|
| 70 |
-
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
| 71 |
-
const ref = useRef<HTMLDivElement>(null);
|
| 72 |
-
useEffect(() => {
|
| 73 |
-
if (!at) return;
|
| 74 |
-
const onDown = (e: MouseEvent) => {
|
| 75 |
-
if (ref.current && !ref.current.contains(e.target as Node)) setAt(null);
|
| 76 |
-
};
|
| 77 |
-
const onKey = (e: KeyboardEvent) => {
|
| 78 |
-
if (e.key === "Escape") setAt(null);
|
| 79 |
-
};
|
| 80 |
-
document.addEventListener("mousedown", onDown, true);
|
| 81 |
-
document.addEventListener("keydown", onKey, true);
|
| 82 |
-
return () => {
|
| 83 |
-
document.removeEventListener("mousedown", onDown, true);
|
| 84 |
-
document.removeEventListener("keydown", onKey, true);
|
| 85 |
-
};
|
| 86 |
-
}, [at]);
|
| 87 |
-
const openFrom = (el: HTMLElement) => {
|
| 88 |
-
const r = el.getBoundingClientRect();
|
| 89 |
-
setAt({ x: Math.round(r.right + 4), y: Math.round(r.top) });
|
| 90 |
-
};
|
| 91 |
-
return { at, ref, openFrom, close: () => setAt(null) };
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
function MenuShell({
|
| 95 |
-
at,
|
| 96 |
-
menuRef,
|
| 97 |
-
wide,
|
| 98 |
-
children,
|
| 99 |
-
}: {
|
| 100 |
-
at: { x: number; y: number };
|
| 101 |
-
menuRef: React.RefObject<HTMLDivElement>;
|
| 102 |
-
/** WAVE 21 item 6 β the delete confirm needs prose width; a menu of one-line
|
| 103 |
-
* actions does not. The CLAMP moves with the class, because a wider panel
|
| 104 |
-
* clamped at the narrow width spills off the right edge on exactly the rows
|
| 105 |
-
* furthest from it. */
|
| 106 |
-
wide?: boolean;
|
| 107 |
-
children: ReactNode;
|
| 108 |
-
}) {
|
| 109 |
-
// Clamp to the viewport so a row near the bottom does not spill the menu off it.
|
| 110 |
-
const width = wide ? 320 : 248;
|
| 111 |
-
const style = {
|
| 112 |
-
left: Math.min(at.x, Math.max(8, window.innerWidth - width)),
|
| 113 |
-
top: Math.min(at.y, Math.max(8, window.innerHeight - 260)),
|
| 114 |
-
};
|
| 115 |
-
return (
|
| 116 |
-
<div
|
| 117 |
-
className={"shell-navmenu" + (wide ? " is-wide" : "")}
|
| 118 |
-
style={style}
|
| 119 |
-
ref={menuRef}
|
| 120 |
-
role="menu"
|
| 121 |
-
>
|
| 122 |
-
{children}
|
| 123 |
-
</div>
|
| 124 |
-
);
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
/**
|
| 128 |
-
* WAVE 19 R8 / C1 β the 12x5 swatch picker, the grid's own pattern
|
| 129 |
-
* (`ViewSidebar.tsx`'s folder form) rendered inside the nav's popover.
|
| 130 |
-
*
|
| 131 |
-
* TWO ROWS, NOT A GRID OF 60. Shape and tone are independent choices, so the
|
| 132 |
-
* shape row previews in the CURRENT tone and the tone row previews in the
|
| 133 |
-
* CURRENT shape β every swatch shows what that one click would actually
|
| 134 |
-
* produce, which is why picking is one glance rather than a search.
|
| 135 |
-
*/
|
| 136 |
-
function IconPicker({
|
| 137 |
-
value,
|
| 138 |
-
onPick,
|
| 139 |
-
onClear,
|
| 140 |
-
}: {
|
| 141 |
-
value?: FolderIcon;
|
| 142 |
-
onPick: (icon: FolderIcon) => void;
|
| 143 |
-
/** Absent β nothing to clear (the row is already on the default mark). */
|
| 144 |
-
onClear?: () => void;
|
| 145 |
-
}) {
|
| 146 |
-
const shape = value?.shape ?? DEFAULT_FOLDER_SHAPE;
|
| 147 |
-
const tone = value?.tone ?? DEFAULT_FOLDER_TONE;
|
| 148 |
-
return (
|
| 149 |
-
<div className="shell-navmenu-pick" role="group" aria-label="Database icon">
|
| 150 |
-
<div className="shell-navmenu-pickrow">
|
| 151 |
-
{FOLDER_SHAPES.map((s) => (
|
| 152 |
-
<button
|
| 153 |
-
key={s}
|
| 154 |
-
type="button"
|
| 155 |
-
className={"shell-navmenu-swatch" + (value && shape === s ? " is-on" : "")}
|
| 156 |
-
aria-pressed={!!value && shape === s}
|
| 157 |
-
aria-label={FOLDER_SHAPE_LABELS[s]}
|
| 158 |
-
title={FOLDER_SHAPE_LABELS[s]}
|
| 159 |
-
onClick={() => onPick({ shape: s, tone })}
|
| 160 |
-
>
|
| 161 |
-
<FolderMark icon={{ shape: s, tone }} size={15} />
|
| 162 |
-
</button>
|
| 163 |
-
))}
|
| 164 |
-
</div>
|
| 165 |
-
<div className="shell-navmenu-pickrow">
|
| 166 |
-
{FOLDER_TONES.map((t) => (
|
| 167 |
-
<button
|
| 168 |
-
key={t}
|
| 169 |
-
type="button"
|
| 170 |
-
className={"shell-navmenu-swatch" + (value && tone === t ? " is-on" : "")}
|
| 171 |
-
aria-pressed={!!value && tone === t}
|
| 172 |
-
aria-label={FOLDER_TONE_LABELS[t]}
|
| 173 |
-
title={FOLDER_TONE_LABELS[t]}
|
| 174 |
-
onClick={() => onPick({ shape, tone: t })}
|
| 175 |
-
>
|
| 176 |
-
<FolderMark icon={{ shape, tone: t }} size={15} />
|
| 177 |
-
</button>
|
| 178 |
-
))}
|
| 179 |
-
</div>
|
| 180 |
-
{/* β AN EXPLICIT WAY BACK, because this picker deliberately does NOT copy
|
| 181 |
-
`cleanFolderIcon`'s "a fully-default icon is no icon" rule. That rule is
|
| 182 |
-
right for a FOLDER, whose default mark IS the folder shape; a database's
|
| 183 |
-
default mark is the cylinder, so dropping folder+neutral would answer a
|
| 184 |
-
user who picked "Folder" with a picture of a database β the silent
|
| 185 |
-
disappearance the tone whitelist's own note warns about. Absent = the
|
| 186 |
-
cylinder, chosen = exactly what was chosen, and clearing is a click that
|
| 187 |
-
says what it does. */}
|
| 188 |
-
{onClear ? (
|
| 189 |
-
<button type="button" className="shell-navmenu-item is-quiet" onClick={onClear}>
|
| 190 |
-
Use the default icon
|
| 191 |
-
</button>
|
| 192 |
-
) : null}
|
| 193 |
-
</div>
|
| 194 |
-
);
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
export function RowMenu({
|
| 198 |
-
entryLabel,
|
| 199 |
-
canSchema,
|
| 200 |
-
onSchema,
|
| 201 |
-
canRename,
|
| 202 |
-
onRename,
|
| 203 |
-
canIcon,
|
| 204 |
-
icon,
|
| 205 |
-
onIcon,
|
| 206 |
-
onIconClear,
|
| 207 |
-
onShare,
|
| 208 |
-
canDelete,
|
| 209 |
-
onLoadFootprint,
|
| 210 |
-
onDelete,
|
| 211 |
-
}: {
|
| 212 |
-
entryLabel: string;
|
| 213 |
-
canSchema: boolean;
|
| 214 |
-
onSchema: () => void;
|
| 215 |
-
/**
|
| 216 |
-
* WAVE 19 R8 β rename is `ut_*` (custom) DATABASES ONLY, and this flag is how
|
| 217 |
-
* the row says so. A built-in label is a compiled registry literal: renaming
|
| 218 |
-
* `customer_data` would mean the nav and every other reader of
|
| 219 |
-
* `core/registry.py` disagreeing about what the module is called. The SERVER
|
| 220 |
-
* refuses a `name` for a non-`ut_` key regardless (C1, fail-closed) β this
|
| 221 |
-
* only spares the user a control whose write would bounce.
|
| 222 |
-
*/
|
| 223 |
-
canRename?: boolean;
|
| 224 |
-
onRename?: (name: string) => void;
|
| 225 |
-
/** Icons ride ALL databases (R8), unlike rename. */
|
| 226 |
-
canIcon?: boolean;
|
| 227 |
-
icon?: FolderIcon;
|
| 228 |
-
onIcon?: (icon: FolderIcon) => void;
|
| 229 |
-
onIconClear?: () => void;
|
| 230 |
-
/** WAVE 20 item 18 (C-SHARE) β open the access editor for THIS database.
|
| 231 |
-
* Absent = the row does not offer sharing (a group head has nothing to share). */
|
| 232 |
-
onShare?: () => void;
|
| 233 |
-
/**
|
| 234 |
-
* β WAVE 21 item 6 (ruling R3, contract C3, wiring W-5) β may this session
|
| 235 |
-
* DELETE this database?
|
| 236 |
-
*
|
| 237 |
-
* β REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type.
|
| 238 |
-
* Four features shipped dead behind 43 green gates because the prop that carried
|
| 239 |
-
* them across an ownership fence was `?`-marked: an unmounted optional prop is
|
| 240 |
-
* `undefined`, which reads as "the feature is off" and is indistinguishable from
|
| 241 |
-
* "the feature was never built". A required prop makes the unmounted case a
|
| 242 |
-
* COMPILE ERROR. The value is the server's (`NavPage.canDelete`), narrower than
|
| 243 |
-
* `manage` on purpose β see the note there.
|
| 244 |
-
*/
|
| 245 |
-
canDelete: boolean;
|
| 246 |
-
/** What deleting would destroy. Resolves null when the server would not say β
|
| 247 |
-
* the face then asks WITHOUT counts rather than inventing zeros. */
|
| 248 |
-
onLoadFootprint: () => Promise<TableFootprint | null>;
|
| 249 |
-
/** Do it. Resolves the server's own words on refusal, which the face shows in
|
| 250 |
-
* place of the confirmation β a delete that failed must never look like one
|
| 251 |
-
* that worked. */
|
| 252 |
-
onDelete: () => Promise<{ ok: boolean; error?: string }>;
|
| 253 |
-
}) {
|
| 254 |
-
// Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
|
| 255 |
-
// Wave 19 (R8/C1) adds Rename and Change icon β the first two things a
|
| 256 |
-
// database the USER made should have been able to do.
|
| 257 |
-
const { at, ref, openFrom, close } = useMenu();
|
| 258 |
-
// Which face the popover is showing. Reset on every open, so a menu that was
|
| 259 |
-
// left mid-rename does not reopen into somebody else's half-typed name.
|
| 260 |
-
const [mode, setMode] = useState<"menu" | "rename" | "icon" | "delete">("menu");
|
| 261 |
-
const [name, setName] = useState(entryLabel);
|
| 262 |
-
/**
|
| 263 |
-
* WAVE 21 item 6 β the delete face's own state. `footprint: undefined` means
|
| 264 |
-
* "still asking", `null` means "the server would not say" β two different
|
| 265 |
-
* things the face words differently, which is why it is not a plain object.
|
| 266 |
-
*/
|
| 267 |
-
const [del, setDel] = useState<{
|
| 268 |
-
footprint?: TableFootprint | null;
|
| 269 |
-
busy: boolean;
|
| 270 |
-
error: string;
|
| 271 |
-
}>({ busy: false, error: "" });
|
| 272 |
-
const renameOn = !!canRename && !!onRename;
|
| 273 |
-
const iconOn = !!canIcon && !!onIcon;
|
| 274 |
-
if (!canSchema && !renameOn && !iconOn && !onShare && !canDelete) return null;
|
| 275 |
-
const submitRename = () => {
|
| 276 |
-
const clean = name.trim().replace(/\s+/g, " ");
|
| 277 |
-
if (clean && clean !== entryLabel) onRename?.(clean);
|
| 278 |
-
close();
|
| 279 |
-
};
|
| 280 |
-
/**
|
| 281 |
-
* β THE FOOTPRINT IS FETCHED WHEN THE FACE OPENS, NOT WHEN THE MENU DOES.
|
| 282 |
-
* Same reasoning as `PermsEditor.prepareCopy` ("the targets are fetched BEFORE
|
| 283 |
-
* the confirm"): the question has to name what is being destroyed, and a
|
| 284 |
-
* confirm written from the rail's own knowledge can only name the table. The
|
| 285 |
-
* cost is paid by the person who clicked Delete, never by everyone who opened
|
| 286 |
-
* a menu.
|
| 287 |
-
*/
|
| 288 |
-
const openDelete = () => {
|
| 289 |
-
setDel({ busy: false, error: "" });
|
| 290 |
-
setMode("delete");
|
| 291 |
-
void onLoadFootprint().then((f) => setDel((cur) => ({ ...cur, footprint: f })));
|
| 292 |
-
};
|
| 293 |
-
const runDelete = () => {
|
| 294 |
-
setDel((cur) => ({ ...cur, busy: true, error: "" }));
|
| 295 |
-
void onDelete().then((r) => {
|
| 296 |
-
if (r.ok) {
|
| 297 |
-
close();
|
| 298 |
-
setDel({ busy: false, error: "" });
|
| 299 |
-
return;
|
| 300 |
-
}
|
| 301 |
-
// β THE MENU STAYS OPEN ON A REFUSAL. Closing it would leave a rail whose
|
| 302 |
-
// row is still there and no statement anywhere about why β which reads as
|
| 303 |
-
// "the click did nothing" and invites a second one.
|
| 304 |
-
setDel((cur) => ({ ...cur, busy: false, error: r.error || "It could not be deleted." }));
|
| 305 |
-
});
|
| 306 |
-
};
|
| 307 |
-
return (
|
| 308 |
-
<>
|
| 309 |
-
<button
|
| 310 |
-
type="button"
|
| 311 |
-
className="shell-dots"
|
| 312 |
-
aria-label={`Options for ${entryLabel}`}
|
| 313 |
-
aria-haspopup="menu"
|
| 314 |
-
onClick={(e) => {
|
| 315 |
-
e.preventDefault();
|
| 316 |
-
e.stopPropagation();
|
| 317 |
-
setMode("menu");
|
| 318 |
-
setName(entryLabel);
|
| 319 |
-
openFrom(e.currentTarget);
|
| 320 |
-
}}
|
| 321 |
-
>
|
| 322 |
-
<DotsIcon />
|
| 323 |
-
</button>
|
| 324 |
-
{at && (
|
| 325 |
-
<MenuShell at={at} menuRef={ref} wide={mode === "delete"}>
|
| 326 |
-
{mode === "delete" && canDelete ? (
|
| 327 |
-
<DeleteFace
|
| 328 |
-
entryLabel={entryLabel}
|
| 329 |
-
state={del}
|
| 330 |
-
onCancel={() => setMode("menu")}
|
| 331 |
-
onConfirm={runDelete}
|
| 332 |
-
/>
|
| 333 |
-
) : mode === "rename" && renameOn ? (
|
| 334 |
-
<form
|
| 335 |
-
className="shell-navmenu-form"
|
| 336 |
-
onSubmit={(e) => {
|
| 337 |
-
e.preventDefault();
|
| 338 |
-
submitRename();
|
| 339 |
-
}}
|
| 340 |
-
>
|
| 341 |
-
<input
|
| 342 |
-
className="shell-navmenu-input"
|
| 343 |
-
autoFocus
|
| 344 |
-
maxLength={60}
|
| 345 |
-
value={name}
|
| 346 |
-
aria-label={`Rename ${entryLabel}`}
|
| 347 |
-
onChange={(e) => setName(e.target.value)}
|
| 348 |
-
onKeyDown={(e) => {
|
| 349 |
-
if (e.key === "Escape") close();
|
| 350 |
-
}}
|
| 351 |
-
/>
|
| 352 |
-
<button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| 353 |
-
Save
|
| 354 |
-
</button>
|
| 355 |
-
</form>
|
| 356 |
-
) : mode === "icon" && iconOn ? (
|
| 357 |
-
/* β PICKING DOES NOT CLOSE THE MENU, and that is not a convenience.
|
| 358 |
-
Shape and tone are two independent choices: a picker that closed
|
| 359 |
-
on the first click would make "a green bolt" a two-open job, and
|
| 360 |
-
the second open would have to find the row again. Each click
|
| 361 |
-
commits (they are cheap and rare), the rail repaints from the
|
| 362 |
-
server, and the swatch marks follow what was actually stored β so
|
| 363 |
-
a refused write shows up as a mark that does not move, beside the
|
| 364 |
-
toast that says why. Clearing DOES close: "use the default" is a
|
| 365 |
-
terminal action, and the control removes itself once taken. */
|
| 366 |
-
<IconPicker
|
| 367 |
-
value={icon}
|
| 368 |
-
onPick={(next) => onIcon?.(next)}
|
| 369 |
-
{...(icon && onIconClear
|
| 370 |
-
? {
|
| 371 |
-
onClear: () => {
|
| 372 |
-
close();
|
| 373 |
-
onIconClear();
|
| 374 |
-
},
|
| 375 |
-
}
|
| 376 |
-
: {})}
|
| 377 |
-
/>
|
| 378 |
-
) : (
|
| 379 |
-
<>
|
| 380 |
-
{renameOn ? (
|
| 381 |
-
<button
|
| 382 |
-
type="button"
|
| 383 |
-
className="shell-navmenu-item"
|
| 384 |
-
onClick={() => setMode("rename")}
|
| 385 |
-
>
|
| 386 |
-
Rename
|
| 387 |
-
</button>
|
| 388 |
-
) : null}
|
| 389 |
-
{iconOn ? (
|
| 390 |
-
<button
|
| 391 |
-
type="button"
|
| 392 |
-
className="shell-navmenu-item"
|
| 393 |
-
onClick={() => setMode("icon")}
|
| 394 |
-
>
|
| 395 |
-
Change icon
|
| 396 |
-
</button>
|
| 397 |
-
) : null}
|
| 398 |
-
{canSchema ? (
|
| 399 |
-
<button
|
| 400 |
-
type="button"
|
| 401 |
-
className="shell-navmenu-item"
|
| 402 |
-
onClick={() => {
|
| 403 |
-
close();
|
| 404 |
-
onSchema();
|
| 405 |
-
}}
|
| 406 |
-
>
|
| 407 |
-
View schema
|
| 408 |
-
</button>
|
| 409 |
-
) : null}
|
| 410 |
-
{/* WAVE 20 item 18 (R10, C-SHARE) β a DATABASE shares with the same two
|
| 411 |
-
roles a view does. Gated on `onShare`, which the Shell passes only
|
| 412 |
-
for a real database row, so a family head or a hand-off link never
|
| 413 |
-
offers to share something that has no id on the server. */}
|
| 414 |
-
{onShare ? (
|
| 415 |
-
<button
|
| 416 |
-
type="button"
|
| 417 |
-
className="shell-navmenu-item"
|
| 418 |
-
onClick={() => {
|
| 419 |
-
close();
|
| 420 |
-
onShare();
|
| 421 |
-
}}
|
| 422 |
-
>
|
| 423 |
-
Shareβ¦
|
| 424 |
-
</button>
|
| 425 |
-
) : null}
|
| 426 |
-
{/* β WAVE 21 item 6 (R3) β LAST, and the only destructive row in this
|
| 427 |
-
menu. Gated on the server's `canDelete` alone: R3 scopes the verb to
|
| 428 |
-
the CREATOR or an admin and refuses it outright for connector-backed
|
| 429 |
-
databases, so a row that may not be deleted does not say so β it
|
| 430 |
-
simply has nothing here to click. (An entry that explains why it is
|
| 431 |
-
disabled is the right pattern for a thing you could earn; this one
|
| 432 |
-
you cannot.) */}
|
| 433 |
-
{canDelete ? (
|
| 434 |
-
<button
|
| 435 |
-
type="button"
|
| 436 |
-
className="shell-navmenu-item is-danger"
|
| 437 |
-
onClick={openDelete}
|
| 438 |
-
>
|
| 439 |
-
Delete databaseβ¦
|
| 440 |
-
</button>
|
| 441 |
-
) : null}
|
| 442 |
-
</>
|
| 443 |
-
)}
|
| 444 |
-
</MenuShell>
|
| 445 |
-
)}
|
| 446 |
-
</>
|
| 447 |
-
);
|
| 448 |
-
}
|
| 449 |
-
|
| 450 |
-
/** One footprint line: "12 records", "1 view". Singular/plural is the whole of
|
| 451 |
-
* the formatting, because a count with the wrong noun reads as a bug in the
|
| 452 |
-
* count. */
|
| 453 |
-
function countLine(n: number, one: string, many: string): string {
|
| 454 |
-
return `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
/**
|
| 458 |
-
* β WAVE 21 item 6 (R3, contract C3) β the confirm face: what deleting this
|
| 459 |
-
* database destroys, stated before the button that does it.
|
| 460 |
-
*
|
| 461 |
-
* Shaped after `PermsEditor`'s confirm (`.set-confirm`, the strongest one this
|
| 462 |
-
* codebase has) and not after a `window.confirm`: the browser dialog cannot say
|
| 463 |
-
* anything specific, and a destructive question whose answer depends on facts
|
| 464 |
-
* the asker has not been given is not really being asked.
|
| 465 |
-
*
|
| 466 |
-
* THREE STATES, worded differently on purpose:
|
| 467 |
-
* Β· footprint undefined β still asking the server. The button is disabled;
|
| 468 |
-
* confirming against numbers that have not arrived is confirming against
|
| 469 |
-
* nothing.
|
| 470 |
-
* Β· footprint null β the server would not say. The question is put
|
| 471 |
-
* WITHOUT counts and says so. Inventing zeros here would be a specific,
|
| 472 |
-
* checkable claim about the user's data that nobody verified
|
| 473 |
-
* ([[no-unverifiable-aggregates]]).
|
| 474 |
-
* Β· footprint present β every family it names, listed.
|
| 475 |
-
*
|
| 476 |
-
* β The automations line is the one that is NOT about destruction: C3 pauses
|
| 477 |
-
* bound automations and stamps them "target deleted" rather than removing them,
|
| 478 |
-
* so it names them (they will still be there afterwards, switched off) instead
|
| 479 |
-
* of counting them away.
|
| 480 |
-
*/
|
| 481 |
-
function DeleteFace({
|
| 482 |
-
entryLabel,
|
| 483 |
-
state,
|
| 484 |
-
onCancel,
|
| 485 |
-
onConfirm,
|
| 486 |
-
}: {
|
| 487 |
-
entryLabel: string;
|
| 488 |
-
state: { footprint?: TableFootprint | null; busy: boolean; error: string };
|
| 489 |
-
onCancel: () => void;
|
| 490 |
-
onConfirm: () => void;
|
| 491 |
-
}) {
|
| 492 |
-
const f = state.footprint;
|
| 493 |
-
const asking = f === undefined;
|
| 494 |
-
return (
|
| 495 |
-
<div className="shell-navconfirm" role="alertdialog" aria-modal="true"
|
| 496 |
-
aria-label={`Delete ${entryLabel}`}>
|
| 497 |
-
<h4 className="shell-navconfirm-h">Delete β{entryLabel}β?</h4>
|
| 498 |
-
{asking ? (
|
| 499 |
-
<p className="shell-navmenu-note">Checking what this database holdsβ¦</p>
|
| 500 |
-
) : f === null ? (
|
| 501 |
-
<p className="shell-navmenu-note">
|
| 502 |
-
The server did not say what this database holds. Deleting it removes its records,
|
| 503 |
-
columns, views, comments, documents and sharing β permanently.
|
| 504 |
-
</p>
|
| 505 |
-
) : (
|
| 506 |
-
<>
|
| 507 |
-
<p className="shell-navmenu-note">This cannot be undone. It permanently removes:</p>
|
| 508 |
-
<ul className="shell-navconfirm-list">
|
| 509 |
-
<li>{countLine(f.rows, "record", "records")}</li>
|
| 510 |
-
<li>{countLine(f.fields, "column", "columns")}</li>
|
| 511 |
-
<li>{countLine(f.views, "view", "views")}</li>
|
| 512 |
-
<li>
|
| 513 |
-
{countLine(f.sharedUsers, "person it is shared with",
|
| 514 |
-
"people it is shared with")}
|
| 515 |
-
</li>
|
| 516 |
-
</ul>
|
| 517 |
-
{f.automations.length ? (
|
| 518 |
-
<p className="shell-navmenu-note">
|
| 519 |
-
{/* The VERB agrees too. "1 automation write here" was on screen before the
|
| 520 |
-
first screenshot was read β the kind of thing every assertion passes. */}
|
| 521 |
-
{countLine(f.automations.length, "automation", "automations")}{" "}
|
| 522 |
-
{f.automations.length === 1 ? "writes" : "write"} here (
|
| 523 |
-
{f.automations.map((a) => a.name).join(", ")}) β {f.automations.length === 1
|
| 524 |
-
? "it is"
|
| 525 |
-
: "they are"}{" "}
|
| 526 |
-
switched off, not deleted.
|
| 527 |
-
</p>
|
| 528 |
-
) : null}
|
| 529 |
-
</>
|
| 530 |
-
)}
|
| 531 |
-
{state.error ? <p className="shell-navconfirm-err">{state.error}</p> : null}
|
| 532 |
-
<div className="shell-navconfirm-actions">
|
| 533 |
-
<button
|
| 534 |
-
type="button"
|
| 535 |
-
className="shell-navconfirm-go"
|
| 536 |
-
disabled={asking || state.busy}
|
| 537 |
-
onClick={onConfirm}
|
| 538 |
-
>
|
| 539 |
-
{state.busy ? "Deletingβ¦" : "Delete"}
|
| 540 |
-
</button>
|
| 541 |
-
<button
|
| 542 |
-
type="button"
|
| 543 |
-
className="shell-navmenu-item is-quiet"
|
| 544 |
-
disabled={state.busy}
|
| 545 |
-
onClick={onCancel}
|
| 546 |
-
>
|
| 547 |
-
Keep it
|
| 548 |
-
</button>
|
| 549 |
-
</div>
|
| 550 |
-
</div>
|
| 551 |
-
);
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
function PlusIcon() {
|
| 555 |
-
return (
|
| 556 |
-
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-newthing-plus">
|
| 557 |
-
<path d="M8 3.4v9.2M3.4 8h9.2" />
|
| 558 |
-
</svg>
|
| 559 |
-
);
|
| 560 |
-
}
|
| 561 |
-
|
| 562 |
-
/**
|
| 563 |
-
* Wave 14 C-NAVFOLD gave the list's bottom a quiet "+ New folder" (the Views-rail
|
| 564 |
-
* precedent). WAVE 19 R11 made it "+ Create newβ¦" with Folder | Database behind it.
|
| 565 |
-
*
|
| 566 |
-
* β WAVE 24 items 1 + 15a (ruling R9) β IT LEAVES THE RAIL AND BECOMES THE DATABASE
|
| 567 |
-
* FLYOUT'S FOOTER, and it now carries the creatable things: New database Β·
|
| 568 |
-
* From a template Β· New folder. The flyout's three standalone `.shell-dbfly-make`
|
| 569 |
-
* buttons are deleted; this one control replaces them.
|
| 570 |
-
*
|
| 571 |
-
* β WAVE 25 item 5a (ruling R8) β "AUTOMATED DATABASE" IS DELETED FROM THIS MENU,
|
| 572 |
-
* so it carries THREE rows rather than four. R8: creating a database is one act and
|
| 573 |
-
* pointing an automation at it is another, so the automation door lives on the
|
| 574 |
-
* automation surface (its rail button and its empty state) and this menu stops
|
| 575 |
-
* offering a database that is really an automation.
|
| 576 |
-
* β `From a template` and `New folder` are UNTOUCHED β neither was ever an automated
|
| 577 |
-
* database, and R9 keeps the template door working here (see the Home card's own note
|
| 578 |
-
* on why exactly one of the two template doors is boarded).
|
| 579 |
-
*
|
| 580 |
-
* β AND IT IS ALSO ITEM 1's FIX, which is worth stating because it does not look
|
| 581 |
-
* like a typography change. MEASURED on staging v12: the six rail rows all render
|
| 582 |
-
* 13.8125px / w500 / Inter / rgb(32,36,51) β identical, `<a>` and `<button>` alike.
|
| 583 |
-
* The ONE row in that band that differed was this one: `.shell-newfolder` declares
|
| 584 |
-
* `--lp-fs-2xs` (12.75px) with no `font-weight` (so 400, inherited) and
|
| 585 |
-
* `--lp-muted`. Taking it out of the rail is what makes the band read as one set;
|
| 586 |
-
* nothing about the other six needed changing, and changing them would have been a
|
| 587 |
-
* fix aimed at the wrong row.
|
| 588 |
-
*
|
| 589 |
-
* β THE `canFolder` GATE SURVIVES THE MOVE, and it has to. Wave 19's note: folders
|
| 590 |
-
* over an empty list are an empty gesture, but a freshly provisioned tenant must
|
| 591 |
-
* still be able to create a DATABASE. So with no entries the menu simply omits its
|
| 592 |
-
* folder row rather than the control collapsing to a single-purpose button β inside
|
| 593 |
-
* a panel there is room for a menu whatever the tenant has, which is the one thing
|
| 594 |
-
* the 236px rail could not say.
|
| 595 |
-
*/
|
| 596 |
-
export function CreateNewRow({
|
| 597 |
-
collapsed,
|
| 598 |
-
onExpand,
|
| 599 |
-
onCreateFolder,
|
| 600 |
-
onNewDatabase,
|
| 601 |
-
onFromTemplate,
|
| 602 |
-
canFolder,
|
| 603 |
-
}: {
|
| 604 |
-
collapsed: boolean;
|
| 605 |
-
/** Collapsed, the row's one honest behaviour is "open the rail first". */
|
| 606 |
-
onExpand: () => void;
|
| 607 |
-
onCreateFolder: (name: string) => void;
|
| 608 |
-
onNewDatabase: () => void;
|
| 609 |
-
/** R9's second row β the template picker, over a database you already have. */
|
| 610 |
-
onFromTemplate: () => void;
|
| 611 |
-
/* β `onAutomated` LEFT WITH THE ROW IT OPENED (wave 25 item 5a, R8). */
|
| 612 |
-
/** Folders over an empty list are an empty gesture β the row is omitted, not the menu. */
|
| 613 |
-
canFolder: boolean;
|
| 614 |
-
}) {
|
| 615 |
-
const { at, ref, openFrom, close } = useMenu();
|
| 616 |
-
const [naming, setNaming] = useState(false);
|
| 617 |
-
const [name, setName] = useState("");
|
| 618 |
-
const submit = () => {
|
| 619 |
-
const clean = name.trim().replace(/\s+/g, " ");
|
| 620 |
-
if (!clean) return;
|
| 621 |
-
onCreateFolder(clean);
|
| 622 |
-
setName("");
|
| 623 |
-
setNaming(false);
|
| 624 |
-
};
|
| 625 |
-
// β The 56px strip cannot hold a popover β the same reason the account row
|
| 626 |
-
// expands instead of opening its menu. Preserved from the button this row
|
| 627 |
-
// replaced, which did exactly this before opening its dialog.
|
| 628 |
-
//
|
| 629 |
-
// β WAVE 24: this branch is now UNREACHABLE FROM THE RAIL (the row is not there
|
| 630 |
-
// any more) but it is NOT dead β the flyout can be opened with the rail folded,
|
| 631 |
-
// and `collapsed` is still passed. Kept rather than deleted, because the panel's
|
| 632 |
-
// own note says the folded rail is exactly the state a user who opened it is most
|
| 633 |
-
// likely to be in.
|
| 634 |
-
if (collapsed) {
|
| 635 |
-
return (
|
| 636 |
-
<button
|
| 637 |
-
type="button"
|
| 638 |
-
className="shell-newfolder is-collapsed"
|
| 639 |
-
aria-label="Create new"
|
| 640 |
-
title="Create new"
|
| 641 |
-
onClick={onExpand}
|
| 642 |
-
>
|
| 643 |
-
<PlusIcon />
|
| 644 |
-
</button>
|
| 645 |
-
);
|
| 646 |
-
}
|
| 647 |
-
if (!naming) {
|
| 648 |
-
return (
|
| 649 |
-
<>
|
| 650 |
-
{/* THE LABEL NO LONGER FOLLOWS `canFolder`. In the rail it had to: with one
|
| 651 |
-
real branch, "+ Create newβ¦" beside an `aria-haspopup` was a control
|
| 652 |
-
promising a choice it was not about to offer. In the footer there are
|
| 653 |
-
always at least two (database / template), so the menu is real whatever
|
| 654 |
-
the tenant has and the label can simply be true.
|
| 655 |
-
β TWO, NOT THREE, since R8 took the automated row (wave 25) β still a
|
| 656 |
-
genuine choice, which is the property the label depends on. If a future
|
| 657 |
-
ruling takes the template row as well, this reasoning inverts and the
|
| 658 |
-
control has to become a plain button again. */}
|
| 659 |
-
<button
|
| 660 |
-
type="button"
|
| 661 |
-
className="shell-newfolder"
|
| 662 |
-
aria-haspopup="menu"
|
| 663 |
-
onClick={(e) => openFrom(e.currentTarget)}
|
| 664 |
-
>
|
| 665 |
-
+ Create newβ¦
|
| 666 |
-
</button>
|
| 667 |
-
{at && (
|
| 668 |
-
<MenuShell at={at} menuRef={ref}>
|
| 669 |
-
<button
|
| 670 |
-
type="button"
|
| 671 |
-
className="shell-navmenu-item"
|
| 672 |
-
onClick={() => {
|
| 673 |
-
close();
|
| 674 |
-
onNewDatabase();
|
| 675 |
-
}}
|
| 676 |
-
>
|
| 677 |
-
New database
|
| 678 |
-
</button>
|
| 679 |
-
<button
|
| 680 |
-
type="button"
|
| 681 |
-
className="shell-navmenu-item"
|
| 682 |
-
onClick={() => {
|
| 683 |
-
close();
|
| 684 |
-
onFromTemplate();
|
| 685 |
-
}}
|
| 686 |
-
>
|
| 687 |
-
From a template
|
| 688 |
-
</button>
|
| 689 |
-
{canFolder ? (
|
| 690 |
-
<button
|
| 691 |
-
type="button"
|
| 692 |
-
className="shell-navmenu-item"
|
| 693 |
-
onClick={() => {
|
| 694 |
-
close();
|
| 695 |
-
setNaming(true);
|
| 696 |
-
}}
|
| 697 |
-
>
|
| 698 |
-
New folder
|
| 699 |
-
</button>
|
| 700 |
-
) : null}
|
| 701 |
-
</MenuShell>
|
| 702 |
-
)}
|
| 703 |
-
</>
|
| 704 |
-
);
|
| 705 |
-
}
|
| 706 |
-
return (
|
| 707 |
-
<form
|
| 708 |
-
className="shell-navmenu-form shell-newfolder-form"
|
| 709 |
-
onSubmit={(e) => {
|
| 710 |
-
e.preventDefault();
|
| 711 |
-
submit();
|
| 712 |
-
}}
|
| 713 |
-
>
|
| 714 |
-
<input
|
| 715 |
-
className="shell-navmenu-input"
|
| 716 |
-
autoFocus
|
| 717 |
-
maxLength={40}
|
| 718 |
-
placeholder="Folder name"
|
| 719 |
-
value={name}
|
| 720 |
-
onChange={(e) => setName(e.target.value)}
|
| 721 |
-
onKeyDown={(e) => {
|
| 722 |
-
if (e.key === "Escape") {
|
| 723 |
-
setName("");
|
| 724 |
-
setNaming(false);
|
| 725 |
-
}
|
| 726 |
-
}}
|
| 727 |
-
/>
|
| 728 |
-
<button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| 729 |
-
Add
|
| 730 |
-
</button>
|
| 731 |
-
</form>
|
| 732 |
-
);
|
| 733 |
-
}
|
| 734 |
-
|
| 735 |
-
export function FolderHead({
|
| 736 |
-
folder,
|
| 737 |
-
count,
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
/**
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
const
|
| 764 |
-
const
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
{.
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
<
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
e
|
| 831 |
-
|
| 832 |
-
}
|
| 833 |
-
|
| 834 |
-
<
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
<button
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
}
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
if (
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
return () =>
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
className="shell-schema-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
</>
|
| 988 |
-
)}
|
| 989 |
-
</div>
|
| 990 |
-
</>
|
| 991 |
-
)}
|
| 992 |
-
</aside>
|
| 993 |
-
</div>
|
| 994 |
-
);
|
| 995 |
-
}
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// shell/NavExtras.tsx β C-SCHEMA (wave 2026-08-02): the database list's
|
| 3 |
+
// three-dots menu, folder heads, and the schema drawer.
|
| 4 |
+
//
|
| 5 |
+
// Kept out of Shell.tsx so the shell's frame stays readable: Shell owns the
|
| 6 |
+
// STATE (prefs, open folders, which schema is open) and the save round-trip;
|
| 7 |
+
// these components own only their own popover/drawer chrome. Menus are
|
| 8 |
+
// position:fixed against the trigger β the rail scrolls and clips, so an
|
| 9 |
+
// absolutely-positioned child could never escape it (the same lesson as the
|
| 10 |
+
// collapsed-rail tooltip).
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
import { useEffect, useRef, useState } from "react";
|
| 14 |
+
import type { ReactNode } from "react";
|
| 15 |
+
import { fetchSchema } from "./nav";
|
| 16 |
+
import type { NavFolder, SchemaPayload, TableFootprint } from "./nav";
|
| 17 |
+
// β WAVE 19 R8 / C1 β IMPORTED, NEVER REDEFINED, and the contract says so in as
|
| 18 |
+
// many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
|
| 19 |
+
// shapes, 5 tones); B imports, never redefines"). A second copy of this
|
| 20 |
+
// vocabulary in the shell is a whitelist that can drift by one member from the
|
| 21 |
+
// one the host validates against β and a tone the server does not recognise
|
| 22 |
+
// degrades to the default, so the user's choice disappears on reload with
|
| 23 |
+
// nothing on screen to say why. These are READ-ONLY imports across the session
|
| 24 |
+
// fence: `customer-grid/**` is another session's tree this wave (HARD RULE 3).
|
| 25 |
+
import { FolderMark } from "../customer-grid/icons";
|
| 26 |
+
import { FOLDER_SHAPE_LABELS, FOLDER_TONE_LABELS } from "../customer-grid/iconShapes";
|
| 27 |
+
import {
|
| 28 |
+
DEFAULT_FOLDER_SHAPE,
|
| 29 |
+
DEFAULT_FOLDER_TONE,
|
| 30 |
+
FOLDER_SHAPES,
|
| 31 |
+
FOLDER_TONES,
|
| 32 |
+
} from "../customer-grid/types";
|
| 33 |
+
import type { FolderIcon } from "../customer-grid/types";
|
| 34 |
+
import "./navExtras.css";
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* WAVE 21 item 12 (ruling R11) β HORIZONTAL, and it is the same three dots turned
|
| 38 |
+
* 90Β°, not a different mark.
|
| 39 |
+
*
|
| 40 |
+
* The rail and the views list are read as one surface, and they were drawing the
|
| 41 |
+
* "there is a menu here" affordance two different ways: this glyph stacked its
|
| 42 |
+
* circles (cx fixed, cy walking) while `ViewSidebar`'s row buttons have always
|
| 43 |
+
* rendered three MIDDLE DOTs on the baseline. Same promise, two pictures β the
|
| 44 |
+
* thing R11 names. Only the axis moves: radius, viewBox and the 14px box in
|
| 45 |
+
* `navExtras.css` are untouched, so the hit target and the optical weight are
|
| 46 |
+
* exactly what shipped.
|
| 47 |
+
*/
|
| 48 |
+
function DotsIcon() {
|
| 49 |
+
return (
|
| 50 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-dots-icon">
|
| 51 |
+
<circle cx="3.2" cy="8" r="1.35" />
|
| 52 |
+
<circle cx="8" cy="8" r="1.35" />
|
| 53 |
+
<circle cx="12.8" cy="8" r="1.35" />
|
| 54 |
+
</svg>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function FolderIcon() {
|
| 59 |
+
return (
|
| 60 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-nav-icon">
|
| 61 |
+
<path d="M2.2 4.4c0-.66.54-1.2 1.2-1.2h3l1.4 1.6h4.8c.66 0 1.2.54 1.2 1.2v6c0 .66-.54 1.2-1.2 1.2H3.4c-.66 0-1.2-.54-1.2-1.2z" />
|
| 62 |
+
</svg>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** A fixed-position popover anchored to a trigger rect, closed on outside
|
| 67 |
+
* click or Escape. Small on purpose β the shell does not import the grid's
|
| 68 |
+
* Popover, so the two chrome trees stay independently deletable. */
|
| 69 |
+
function useMenu() {
|
| 70 |
+
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
| 71 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (!at) return;
|
| 74 |
+
const onDown = (e: MouseEvent) => {
|
| 75 |
+
if (ref.current && !ref.current.contains(e.target as Node)) setAt(null);
|
| 76 |
+
};
|
| 77 |
+
const onKey = (e: KeyboardEvent) => {
|
| 78 |
+
if (e.key === "Escape") setAt(null);
|
| 79 |
+
};
|
| 80 |
+
document.addEventListener("mousedown", onDown, true);
|
| 81 |
+
document.addEventListener("keydown", onKey, true);
|
| 82 |
+
return () => {
|
| 83 |
+
document.removeEventListener("mousedown", onDown, true);
|
| 84 |
+
document.removeEventListener("keydown", onKey, true);
|
| 85 |
+
};
|
| 86 |
+
}, [at]);
|
| 87 |
+
const openFrom = (el: HTMLElement) => {
|
| 88 |
+
const r = el.getBoundingClientRect();
|
| 89 |
+
setAt({ x: Math.round(r.right + 4), y: Math.round(r.top) });
|
| 90 |
+
};
|
| 91 |
+
return { at, ref, openFrom, close: () => setAt(null) };
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function MenuShell({
|
| 95 |
+
at,
|
| 96 |
+
menuRef,
|
| 97 |
+
wide,
|
| 98 |
+
children,
|
| 99 |
+
}: {
|
| 100 |
+
at: { x: number; y: number };
|
| 101 |
+
menuRef: React.RefObject<HTMLDivElement>;
|
| 102 |
+
/** WAVE 21 item 6 β the delete confirm needs prose width; a menu of one-line
|
| 103 |
+
* actions does not. The CLAMP moves with the class, because a wider panel
|
| 104 |
+
* clamped at the narrow width spills off the right edge on exactly the rows
|
| 105 |
+
* furthest from it. */
|
| 106 |
+
wide?: boolean;
|
| 107 |
+
children: ReactNode;
|
| 108 |
+
}) {
|
| 109 |
+
// Clamp to the viewport so a row near the bottom does not spill the menu off it.
|
| 110 |
+
const width = wide ? 320 : 248;
|
| 111 |
+
const style = {
|
| 112 |
+
left: Math.min(at.x, Math.max(8, window.innerWidth - width)),
|
| 113 |
+
top: Math.min(at.y, Math.max(8, window.innerHeight - 260)),
|
| 114 |
+
};
|
| 115 |
+
return (
|
| 116 |
+
<div
|
| 117 |
+
className={"shell-navmenu" + (wide ? " is-wide" : "")}
|
| 118 |
+
style={style}
|
| 119 |
+
ref={menuRef}
|
| 120 |
+
role="menu"
|
| 121 |
+
>
|
| 122 |
+
{children}
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* WAVE 19 R8 / C1 β the 12x5 swatch picker, the grid's own pattern
|
| 129 |
+
* (`ViewSidebar.tsx`'s folder form) rendered inside the nav's popover.
|
| 130 |
+
*
|
| 131 |
+
* TWO ROWS, NOT A GRID OF 60. Shape and tone are independent choices, so the
|
| 132 |
+
* shape row previews in the CURRENT tone and the tone row previews in the
|
| 133 |
+
* CURRENT shape β every swatch shows what that one click would actually
|
| 134 |
+
* produce, which is why picking is one glance rather than a search.
|
| 135 |
+
*/
|
| 136 |
+
function IconPicker({
|
| 137 |
+
value,
|
| 138 |
+
onPick,
|
| 139 |
+
onClear,
|
| 140 |
+
}: {
|
| 141 |
+
value?: FolderIcon;
|
| 142 |
+
onPick: (icon: FolderIcon) => void;
|
| 143 |
+
/** Absent β nothing to clear (the row is already on the default mark). */
|
| 144 |
+
onClear?: () => void;
|
| 145 |
+
}) {
|
| 146 |
+
const shape = value?.shape ?? DEFAULT_FOLDER_SHAPE;
|
| 147 |
+
const tone = value?.tone ?? DEFAULT_FOLDER_TONE;
|
| 148 |
+
return (
|
| 149 |
+
<div className="shell-navmenu-pick" role="group" aria-label="Database icon">
|
| 150 |
+
<div className="shell-navmenu-pickrow">
|
| 151 |
+
{FOLDER_SHAPES.map((s) => (
|
| 152 |
+
<button
|
| 153 |
+
key={s}
|
| 154 |
+
type="button"
|
| 155 |
+
className={"shell-navmenu-swatch" + (value && shape === s ? " is-on" : "")}
|
| 156 |
+
aria-pressed={!!value && shape === s}
|
| 157 |
+
aria-label={FOLDER_SHAPE_LABELS[s]}
|
| 158 |
+
title={FOLDER_SHAPE_LABELS[s]}
|
| 159 |
+
onClick={() => onPick({ shape: s, tone })}
|
| 160 |
+
>
|
| 161 |
+
<FolderMark icon={{ shape: s, tone }} size={15} />
|
| 162 |
+
</button>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
<div className="shell-navmenu-pickrow">
|
| 166 |
+
{FOLDER_TONES.map((t) => (
|
| 167 |
+
<button
|
| 168 |
+
key={t}
|
| 169 |
+
type="button"
|
| 170 |
+
className={"shell-navmenu-swatch" + (value && tone === t ? " is-on" : "")}
|
| 171 |
+
aria-pressed={!!value && tone === t}
|
| 172 |
+
aria-label={FOLDER_TONE_LABELS[t]}
|
| 173 |
+
title={FOLDER_TONE_LABELS[t]}
|
| 174 |
+
onClick={() => onPick({ shape, tone: t })}
|
| 175 |
+
>
|
| 176 |
+
<FolderMark icon={{ shape, tone: t }} size={15} />
|
| 177 |
+
</button>
|
| 178 |
+
))}
|
| 179 |
+
</div>
|
| 180 |
+
{/* β AN EXPLICIT WAY BACK, because this picker deliberately does NOT copy
|
| 181 |
+
`cleanFolderIcon`'s "a fully-default icon is no icon" rule. That rule is
|
| 182 |
+
right for a FOLDER, whose default mark IS the folder shape; a database's
|
| 183 |
+
default mark is the cylinder, so dropping folder+neutral would answer a
|
| 184 |
+
user who picked "Folder" with a picture of a database β the silent
|
| 185 |
+
disappearance the tone whitelist's own note warns about. Absent = the
|
| 186 |
+
cylinder, chosen = exactly what was chosen, and clearing is a click that
|
| 187 |
+
says what it does. */}
|
| 188 |
+
{onClear ? (
|
| 189 |
+
<button type="button" className="shell-navmenu-item is-quiet" onClick={onClear}>
|
| 190 |
+
Use the default icon
|
| 191 |
+
</button>
|
| 192 |
+
) : null}
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
export function RowMenu({
|
| 198 |
+
entryLabel,
|
| 199 |
+
canSchema,
|
| 200 |
+
onSchema,
|
| 201 |
+
canRename,
|
| 202 |
+
onRename,
|
| 203 |
+
canIcon,
|
| 204 |
+
icon,
|
| 205 |
+
onIcon,
|
| 206 |
+
onIconClear,
|
| 207 |
+
onShare,
|
| 208 |
+
canDelete,
|
| 209 |
+
onLoadFootprint,
|
| 210 |
+
onDelete,
|
| 211 |
+
}: {
|
| 212 |
+
entryLabel: string;
|
| 213 |
+
canSchema: boolean;
|
| 214 |
+
onSchema: () => void;
|
| 215 |
+
/**
|
| 216 |
+
* WAVE 19 R8 β rename is `ut_*` (custom) DATABASES ONLY, and this flag is how
|
| 217 |
+
* the row says so. A built-in label is a compiled registry literal: renaming
|
| 218 |
+
* `customer_data` would mean the nav and every other reader of
|
| 219 |
+
* `core/registry.py` disagreeing about what the module is called. The SERVER
|
| 220 |
+
* refuses a `name` for a non-`ut_` key regardless (C1, fail-closed) β this
|
| 221 |
+
* only spares the user a control whose write would bounce.
|
| 222 |
+
*/
|
| 223 |
+
canRename?: boolean;
|
| 224 |
+
onRename?: (name: string) => void;
|
| 225 |
+
/** Icons ride ALL databases (R8), unlike rename. */
|
| 226 |
+
canIcon?: boolean;
|
| 227 |
+
icon?: FolderIcon;
|
| 228 |
+
onIcon?: (icon: FolderIcon) => void;
|
| 229 |
+
onIconClear?: () => void;
|
| 230 |
+
/** WAVE 20 item 18 (C-SHARE) β open the access editor for THIS database.
|
| 231 |
+
* Absent = the row does not offer sharing (a group head has nothing to share). */
|
| 232 |
+
onShare?: () => void;
|
| 233 |
+
/**
|
| 234 |
+
* β WAVE 21 item 6 (ruling R3, contract C3, wiring W-5) β may this session
|
| 235 |
+
* DELETE this database?
|
| 236 |
+
*
|
| 237 |
+
* β REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type.
|
| 238 |
+
* Four features shipped dead behind 43 green gates because the prop that carried
|
| 239 |
+
* them across an ownership fence was `?`-marked: an unmounted optional prop is
|
| 240 |
+
* `undefined`, which reads as "the feature is off" and is indistinguishable from
|
| 241 |
+
* "the feature was never built". A required prop makes the unmounted case a
|
| 242 |
+
* COMPILE ERROR. The value is the server's (`NavPage.canDelete`), narrower than
|
| 243 |
+
* `manage` on purpose β see the note there.
|
| 244 |
+
*/
|
| 245 |
+
canDelete: boolean;
|
| 246 |
+
/** What deleting would destroy. Resolves null when the server would not say β
|
| 247 |
+
* the face then asks WITHOUT counts rather than inventing zeros. */
|
| 248 |
+
onLoadFootprint: () => Promise<TableFootprint | null>;
|
| 249 |
+
/** Do it. Resolves the server's own words on refusal, which the face shows in
|
| 250 |
+
* place of the confirmation β a delete that failed must never look like one
|
| 251 |
+
* that worked. */
|
| 252 |
+
onDelete: () => Promise<{ ok: boolean; error?: string }>;
|
| 253 |
+
}) {
|
| 254 |
+
// Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
|
| 255 |
+
// Wave 19 (R8/C1) adds Rename and Change icon β the first two things a
|
| 256 |
+
// database the USER made should have been able to do.
|
| 257 |
+
const { at, ref, openFrom, close } = useMenu();
|
| 258 |
+
// Which face the popover is showing. Reset on every open, so a menu that was
|
| 259 |
+
// left mid-rename does not reopen into somebody else's half-typed name.
|
| 260 |
+
const [mode, setMode] = useState<"menu" | "rename" | "icon" | "delete">("menu");
|
| 261 |
+
const [name, setName] = useState(entryLabel);
|
| 262 |
+
/**
|
| 263 |
+
* WAVE 21 item 6 β the delete face's own state. `footprint: undefined` means
|
| 264 |
+
* "still asking", `null` means "the server would not say" β two different
|
| 265 |
+
* things the face words differently, which is why it is not a plain object.
|
| 266 |
+
*/
|
| 267 |
+
const [del, setDel] = useState<{
|
| 268 |
+
footprint?: TableFootprint | null;
|
| 269 |
+
busy: boolean;
|
| 270 |
+
error: string;
|
| 271 |
+
}>({ busy: false, error: "" });
|
| 272 |
+
const renameOn = !!canRename && !!onRename;
|
| 273 |
+
const iconOn = !!canIcon && !!onIcon;
|
| 274 |
+
if (!canSchema && !renameOn && !iconOn && !onShare && !canDelete) return null;
|
| 275 |
+
const submitRename = () => {
|
| 276 |
+
const clean = name.trim().replace(/\s+/g, " ");
|
| 277 |
+
if (clean && clean !== entryLabel) onRename?.(clean);
|
| 278 |
+
close();
|
| 279 |
+
};
|
| 280 |
+
/**
|
| 281 |
+
* β THE FOOTPRINT IS FETCHED WHEN THE FACE OPENS, NOT WHEN THE MENU DOES.
|
| 282 |
+
* Same reasoning as `PermsEditor.prepareCopy` ("the targets are fetched BEFORE
|
| 283 |
+
* the confirm"): the question has to name what is being destroyed, and a
|
| 284 |
+
* confirm written from the rail's own knowledge can only name the table. The
|
| 285 |
+
* cost is paid by the person who clicked Delete, never by everyone who opened
|
| 286 |
+
* a menu.
|
| 287 |
+
*/
|
| 288 |
+
const openDelete = () => {
|
| 289 |
+
setDel({ busy: false, error: "" });
|
| 290 |
+
setMode("delete");
|
| 291 |
+
void onLoadFootprint().then((f) => setDel((cur) => ({ ...cur, footprint: f })));
|
| 292 |
+
};
|
| 293 |
+
const runDelete = () => {
|
| 294 |
+
setDel((cur) => ({ ...cur, busy: true, error: "" }));
|
| 295 |
+
void onDelete().then((r) => {
|
| 296 |
+
if (r.ok) {
|
| 297 |
+
close();
|
| 298 |
+
setDel({ busy: false, error: "" });
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
// β THE MENU STAYS OPEN ON A REFUSAL. Closing it would leave a rail whose
|
| 302 |
+
// row is still there and no statement anywhere about why β which reads as
|
| 303 |
+
// "the click did nothing" and invites a second one.
|
| 304 |
+
setDel((cur) => ({ ...cur, busy: false, error: r.error || "It could not be deleted." }));
|
| 305 |
+
});
|
| 306 |
+
};
|
| 307 |
+
return (
|
| 308 |
+
<>
|
| 309 |
+
<button
|
| 310 |
+
type="button"
|
| 311 |
+
className="shell-dots"
|
| 312 |
+
aria-label={`Options for ${entryLabel}`}
|
| 313 |
+
aria-haspopup="menu"
|
| 314 |
+
onClick={(e) => {
|
| 315 |
+
e.preventDefault();
|
| 316 |
+
e.stopPropagation();
|
| 317 |
+
setMode("menu");
|
| 318 |
+
setName(entryLabel);
|
| 319 |
+
openFrom(e.currentTarget);
|
| 320 |
+
}}
|
| 321 |
+
>
|
| 322 |
+
<DotsIcon />
|
| 323 |
+
</button>
|
| 324 |
+
{at && (
|
| 325 |
+
<MenuShell at={at} menuRef={ref} wide={mode === "delete"}>
|
| 326 |
+
{mode === "delete" && canDelete ? (
|
| 327 |
+
<DeleteFace
|
| 328 |
+
entryLabel={entryLabel}
|
| 329 |
+
state={del}
|
| 330 |
+
onCancel={() => setMode("menu")}
|
| 331 |
+
onConfirm={runDelete}
|
| 332 |
+
/>
|
| 333 |
+
) : mode === "rename" && renameOn ? (
|
| 334 |
+
<form
|
| 335 |
+
className="shell-navmenu-form"
|
| 336 |
+
onSubmit={(e) => {
|
| 337 |
+
e.preventDefault();
|
| 338 |
+
submitRename();
|
| 339 |
+
}}
|
| 340 |
+
>
|
| 341 |
+
<input
|
| 342 |
+
className="shell-navmenu-input"
|
| 343 |
+
autoFocus
|
| 344 |
+
maxLength={60}
|
| 345 |
+
value={name}
|
| 346 |
+
aria-label={`Rename ${entryLabel}`}
|
| 347 |
+
onChange={(e) => setName(e.target.value)}
|
| 348 |
+
onKeyDown={(e) => {
|
| 349 |
+
if (e.key === "Escape") close();
|
| 350 |
+
}}
|
| 351 |
+
/>
|
| 352 |
+
<button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| 353 |
+
Save
|
| 354 |
+
</button>
|
| 355 |
+
</form>
|
| 356 |
+
) : mode === "icon" && iconOn ? (
|
| 357 |
+
/* β PICKING DOES NOT CLOSE THE MENU, and that is not a convenience.
|
| 358 |
+
Shape and tone are two independent choices: a picker that closed
|
| 359 |
+
on the first click would make "a green bolt" a two-open job, and
|
| 360 |
+
the second open would have to find the row again. Each click
|
| 361 |
+
commits (they are cheap and rare), the rail repaints from the
|
| 362 |
+
server, and the swatch marks follow what was actually stored β so
|
| 363 |
+
a refused write shows up as a mark that does not move, beside the
|
| 364 |
+
toast that says why. Clearing DOES close: "use the default" is a
|
| 365 |
+
terminal action, and the control removes itself once taken. */
|
| 366 |
+
<IconPicker
|
| 367 |
+
value={icon}
|
| 368 |
+
onPick={(next) => onIcon?.(next)}
|
| 369 |
+
{...(icon && onIconClear
|
| 370 |
+
? {
|
| 371 |
+
onClear: () => {
|
| 372 |
+
close();
|
| 373 |
+
onIconClear();
|
| 374 |
+
},
|
| 375 |
+
}
|
| 376 |
+
: {})}
|
| 377 |
+
/>
|
| 378 |
+
) : (
|
| 379 |
+
<>
|
| 380 |
+
{renameOn ? (
|
| 381 |
+
<button
|
| 382 |
+
type="button"
|
| 383 |
+
className="shell-navmenu-item"
|
| 384 |
+
onClick={() => setMode("rename")}
|
| 385 |
+
>
|
| 386 |
+
Rename
|
| 387 |
+
</button>
|
| 388 |
+
) : null}
|
| 389 |
+
{iconOn ? (
|
| 390 |
+
<button
|
| 391 |
+
type="button"
|
| 392 |
+
className="shell-navmenu-item"
|
| 393 |
+
onClick={() => setMode("icon")}
|
| 394 |
+
>
|
| 395 |
+
Change icon
|
| 396 |
+
</button>
|
| 397 |
+
) : null}
|
| 398 |
+
{canSchema ? (
|
| 399 |
+
<button
|
| 400 |
+
type="button"
|
| 401 |
+
className="shell-navmenu-item"
|
| 402 |
+
onClick={() => {
|
| 403 |
+
close();
|
| 404 |
+
onSchema();
|
| 405 |
+
}}
|
| 406 |
+
>
|
| 407 |
+
View schema
|
| 408 |
+
</button>
|
| 409 |
+
) : null}
|
| 410 |
+
{/* WAVE 20 item 18 (R10, C-SHARE) β a DATABASE shares with the same two
|
| 411 |
+
roles a view does. Gated on `onShare`, which the Shell passes only
|
| 412 |
+
for a real database row, so a family head or a hand-off link never
|
| 413 |
+
offers to share something that has no id on the server. */}
|
| 414 |
+
{onShare ? (
|
| 415 |
+
<button
|
| 416 |
+
type="button"
|
| 417 |
+
className="shell-navmenu-item"
|
| 418 |
+
onClick={() => {
|
| 419 |
+
close();
|
| 420 |
+
onShare();
|
| 421 |
+
}}
|
| 422 |
+
>
|
| 423 |
+
Shareβ¦
|
| 424 |
+
</button>
|
| 425 |
+
) : null}
|
| 426 |
+
{/* β WAVE 21 item 6 (R3) β LAST, and the only destructive row in this
|
| 427 |
+
menu. Gated on the server's `canDelete` alone: R3 scopes the verb to
|
| 428 |
+
the CREATOR or an admin and refuses it outright for connector-backed
|
| 429 |
+
databases, so a row that may not be deleted does not say so β it
|
| 430 |
+
simply has nothing here to click. (An entry that explains why it is
|
| 431 |
+
disabled is the right pattern for a thing you could earn; this one
|
| 432 |
+
you cannot.) */}
|
| 433 |
+
{canDelete ? (
|
| 434 |
+
<button
|
| 435 |
+
type="button"
|
| 436 |
+
className="shell-navmenu-item is-danger"
|
| 437 |
+
onClick={openDelete}
|
| 438 |
+
>
|
| 439 |
+
Delete databaseβ¦
|
| 440 |
+
</button>
|
| 441 |
+
) : null}
|
| 442 |
+
</>
|
| 443 |
+
)}
|
| 444 |
+
</MenuShell>
|
| 445 |
+
)}
|
| 446 |
+
</>
|
| 447 |
+
);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/** One footprint line: "12 records", "1 view". Singular/plural is the whole of
|
| 451 |
+
* the formatting, because a count with the wrong noun reads as a bug in the
|
| 452 |
+
* count. */
|
| 453 |
+
function countLine(n: number, one: string, many: string): string {
|
| 454 |
+
return `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
/**
|
| 458 |
+
* β WAVE 21 item 6 (R3, contract C3) β the confirm face: what deleting this
|
| 459 |
+
* database destroys, stated before the button that does it.
|
| 460 |
+
*
|
| 461 |
+
* Shaped after `PermsEditor`'s confirm (`.set-confirm`, the strongest one this
|
| 462 |
+
* codebase has) and not after a `window.confirm`: the browser dialog cannot say
|
| 463 |
+
* anything specific, and a destructive question whose answer depends on facts
|
| 464 |
+
* the asker has not been given is not really being asked.
|
| 465 |
+
*
|
| 466 |
+
* THREE STATES, worded differently on purpose:
|
| 467 |
+
* Β· footprint undefined β still asking the server. The button is disabled;
|
| 468 |
+
* confirming against numbers that have not arrived is confirming against
|
| 469 |
+
* nothing.
|
| 470 |
+
* Β· footprint null β the server would not say. The question is put
|
| 471 |
+
* WITHOUT counts and says so. Inventing zeros here would be a specific,
|
| 472 |
+
* checkable claim about the user's data that nobody verified
|
| 473 |
+
* ([[no-unverifiable-aggregates]]).
|
| 474 |
+
* Β· footprint present β every family it names, listed.
|
| 475 |
+
*
|
| 476 |
+
* β The automations line is the one that is NOT about destruction: C3 pauses
|
| 477 |
+
* bound automations and stamps them "target deleted" rather than removing them,
|
| 478 |
+
* so it names them (they will still be there afterwards, switched off) instead
|
| 479 |
+
* of counting them away.
|
| 480 |
+
*/
|
| 481 |
+
function DeleteFace({
|
| 482 |
+
entryLabel,
|
| 483 |
+
state,
|
| 484 |
+
onCancel,
|
| 485 |
+
onConfirm,
|
| 486 |
+
}: {
|
| 487 |
+
entryLabel: string;
|
| 488 |
+
state: { footprint?: TableFootprint | null; busy: boolean; error: string };
|
| 489 |
+
onCancel: () => void;
|
| 490 |
+
onConfirm: () => void;
|
| 491 |
+
}) {
|
| 492 |
+
const f = state.footprint;
|
| 493 |
+
const asking = f === undefined;
|
| 494 |
+
return (
|
| 495 |
+
<div className="shell-navconfirm" role="alertdialog" aria-modal="true"
|
| 496 |
+
aria-label={`Delete ${entryLabel}`}>
|
| 497 |
+
<h4 className="shell-navconfirm-h">Delete β{entryLabel}β?</h4>
|
| 498 |
+
{asking ? (
|
| 499 |
+
<p className="shell-navmenu-note">Checking what this database holdsβ¦</p>
|
| 500 |
+
) : f === null ? (
|
| 501 |
+
<p className="shell-navmenu-note">
|
| 502 |
+
The server did not say what this database holds. Deleting it removes its records,
|
| 503 |
+
columns, views, comments, documents and sharing β permanently.
|
| 504 |
+
</p>
|
| 505 |
+
) : (
|
| 506 |
+
<>
|
| 507 |
+
<p className="shell-navmenu-note">This cannot be undone. It permanently removes:</p>
|
| 508 |
+
<ul className="shell-navconfirm-list">
|
| 509 |
+
<li>{countLine(f.rows, "record", "records")}</li>
|
| 510 |
+
<li>{countLine(f.fields, "column", "columns")}</li>
|
| 511 |
+
<li>{countLine(f.views, "view", "views")}</li>
|
| 512 |
+
<li>
|
| 513 |
+
{countLine(f.sharedUsers, "person it is shared with",
|
| 514 |
+
"people it is shared with")}
|
| 515 |
+
</li>
|
| 516 |
+
</ul>
|
| 517 |
+
{f.automations.length ? (
|
| 518 |
+
<p className="shell-navmenu-note">
|
| 519 |
+
{/* The VERB agrees too. "1 automation write here" was on screen before the
|
| 520 |
+
first screenshot was read β the kind of thing every assertion passes. */}
|
| 521 |
+
{countLine(f.automations.length, "automation", "automations")}{" "}
|
| 522 |
+
{f.automations.length === 1 ? "writes" : "write"} here (
|
| 523 |
+
{f.automations.map((a) => a.name).join(", ")}) β {f.automations.length === 1
|
| 524 |
+
? "it is"
|
| 525 |
+
: "they are"}{" "}
|
| 526 |
+
switched off, not deleted.
|
| 527 |
+
</p>
|
| 528 |
+
) : null}
|
| 529 |
+
</>
|
| 530 |
+
)}
|
| 531 |
+
{state.error ? <p className="shell-navconfirm-err">{state.error}</p> : null}
|
| 532 |
+
<div className="shell-navconfirm-actions">
|
| 533 |
+
<button
|
| 534 |
+
type="button"
|
| 535 |
+
className="shell-navconfirm-go"
|
| 536 |
+
disabled={asking || state.busy}
|
| 537 |
+
onClick={onConfirm}
|
| 538 |
+
>
|
| 539 |
+
{state.busy ? "Deletingβ¦" : "Delete"}
|
| 540 |
+
</button>
|
| 541 |
+
<button
|
| 542 |
+
type="button"
|
| 543 |
+
className="shell-navmenu-item is-quiet"
|
| 544 |
+
disabled={state.busy}
|
| 545 |
+
onClick={onCancel}
|
| 546 |
+
>
|
| 547 |
+
Keep it
|
| 548 |
+
</button>
|
| 549 |
+
</div>
|
| 550 |
+
</div>
|
| 551 |
+
);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
function PlusIcon() {
|
| 555 |
+
return (
|
| 556 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className="shell-newthing-plus">
|
| 557 |
+
<path d="M8 3.4v9.2M3.4 8h9.2" />
|
| 558 |
+
</svg>
|
| 559 |
+
);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/**
|
| 563 |
+
* Wave 14 C-NAVFOLD gave the list's bottom a quiet "+ New folder" (the Views-rail
|
| 564 |
+
* precedent). WAVE 19 R11 made it "+ Create newβ¦" with Folder | Database behind it.
|
| 565 |
+
*
|
| 566 |
+
* β WAVE 24 items 1 + 15a (ruling R9) β IT LEAVES THE RAIL AND BECOMES THE DATABASE
|
| 567 |
+
* FLYOUT'S FOOTER, and it now carries the creatable things: New database Β·
|
| 568 |
+
* From a template Β· New folder. The flyout's three standalone `.shell-dbfly-make`
|
| 569 |
+
* buttons are deleted; this one control replaces them.
|
| 570 |
+
*
|
| 571 |
+
* β WAVE 25 item 5a (ruling R8) β "AUTOMATED DATABASE" IS DELETED FROM THIS MENU,
|
| 572 |
+
* so it carries THREE rows rather than four. R8: creating a database is one act and
|
| 573 |
+
* pointing an automation at it is another, so the automation door lives on the
|
| 574 |
+
* automation surface (its rail button and its empty state) and this menu stops
|
| 575 |
+
* offering a database that is really an automation.
|
| 576 |
+
* β `From a template` and `New folder` are UNTOUCHED β neither was ever an automated
|
| 577 |
+
* database, and R9 keeps the template door working here (see the Home card's own note
|
| 578 |
+
* on why exactly one of the two template doors is boarded).
|
| 579 |
+
*
|
| 580 |
+
* β AND IT IS ALSO ITEM 1's FIX, which is worth stating because it does not look
|
| 581 |
+
* like a typography change. MEASURED on staging v12: the six rail rows all render
|
| 582 |
+
* 13.8125px / w500 / Inter / rgb(32,36,51) β identical, `<a>` and `<button>` alike.
|
| 583 |
+
* The ONE row in that band that differed was this one: `.shell-newfolder` declares
|
| 584 |
+
* `--lp-fs-2xs` (12.75px) with no `font-weight` (so 400, inherited) and
|
| 585 |
+
* `--lp-muted`. Taking it out of the rail is what makes the band read as one set;
|
| 586 |
+
* nothing about the other six needed changing, and changing them would have been a
|
| 587 |
+
* fix aimed at the wrong row.
|
| 588 |
+
*
|
| 589 |
+
* β THE `canFolder` GATE SURVIVES THE MOVE, and it has to. Wave 19's note: folders
|
| 590 |
+
* over an empty list are an empty gesture, but a freshly provisioned tenant must
|
| 591 |
+
* still be able to create a DATABASE. So with no entries the menu simply omits its
|
| 592 |
+
* folder row rather than the control collapsing to a single-purpose button β inside
|
| 593 |
+
* a panel there is room for a menu whatever the tenant has, which is the one thing
|
| 594 |
+
* the 236px rail could not say.
|
| 595 |
+
*/
|
| 596 |
+
export function CreateNewRow({
|
| 597 |
+
collapsed,
|
| 598 |
+
onExpand,
|
| 599 |
+
onCreateFolder,
|
| 600 |
+
onNewDatabase,
|
| 601 |
+
onFromTemplate,
|
| 602 |
+
canFolder,
|
| 603 |
+
}: {
|
| 604 |
+
collapsed: boolean;
|
| 605 |
+
/** Collapsed, the row's one honest behaviour is "open the rail first". */
|
| 606 |
+
onExpand: () => void;
|
| 607 |
+
onCreateFolder: (name: string) => void;
|
| 608 |
+
onNewDatabase: () => void;
|
| 609 |
+
/** R9's second row β the template picker, over a database you already have. */
|
| 610 |
+
onFromTemplate: () => void;
|
| 611 |
+
/* β `onAutomated` LEFT WITH THE ROW IT OPENED (wave 25 item 5a, R8). */
|
| 612 |
+
/** Folders over an empty list are an empty gesture β the row is omitted, not the menu. */
|
| 613 |
+
canFolder: boolean;
|
| 614 |
+
}) {
|
| 615 |
+
const { at, ref, openFrom, close } = useMenu();
|
| 616 |
+
const [naming, setNaming] = useState(false);
|
| 617 |
+
const [name, setName] = useState("");
|
| 618 |
+
const submit = () => {
|
| 619 |
+
const clean = name.trim().replace(/\s+/g, " ");
|
| 620 |
+
if (!clean) return;
|
| 621 |
+
onCreateFolder(clean);
|
| 622 |
+
setName("");
|
| 623 |
+
setNaming(false);
|
| 624 |
+
};
|
| 625 |
+
// β The 56px strip cannot hold a popover β the same reason the account row
|
| 626 |
+
// expands instead of opening its menu. Preserved from the button this row
|
| 627 |
+
// replaced, which did exactly this before opening its dialog.
|
| 628 |
+
//
|
| 629 |
+
// β WAVE 24: this branch is now UNREACHABLE FROM THE RAIL (the row is not there
|
| 630 |
+
// any more) but it is NOT dead β the flyout can be opened with the rail folded,
|
| 631 |
+
// and `collapsed` is still passed. Kept rather than deleted, because the panel's
|
| 632 |
+
// own note says the folded rail is exactly the state a user who opened it is most
|
| 633 |
+
// likely to be in.
|
| 634 |
+
if (collapsed) {
|
| 635 |
+
return (
|
| 636 |
+
<button
|
| 637 |
+
type="button"
|
| 638 |
+
className="shell-newfolder is-collapsed"
|
| 639 |
+
aria-label="Create new"
|
| 640 |
+
title="Create new"
|
| 641 |
+
onClick={onExpand}
|
| 642 |
+
>
|
| 643 |
+
<PlusIcon />
|
| 644 |
+
</button>
|
| 645 |
+
);
|
| 646 |
+
}
|
| 647 |
+
if (!naming) {
|
| 648 |
+
return (
|
| 649 |
+
<>
|
| 650 |
+
{/* THE LABEL NO LONGER FOLLOWS `canFolder`. In the rail it had to: with one
|
| 651 |
+
real branch, "+ Create newβ¦" beside an `aria-haspopup` was a control
|
| 652 |
+
promising a choice it was not about to offer. In the footer there are
|
| 653 |
+
always at least two (database / template), so the menu is real whatever
|
| 654 |
+
the tenant has and the label can simply be true.
|
| 655 |
+
β TWO, NOT THREE, since R8 took the automated row (wave 25) β still a
|
| 656 |
+
genuine choice, which is the property the label depends on. If a future
|
| 657 |
+
ruling takes the template row as well, this reasoning inverts and the
|
| 658 |
+
control has to become a plain button again. */}
|
| 659 |
+
<button
|
| 660 |
+
type="button"
|
| 661 |
+
className="shell-newfolder"
|
| 662 |
+
aria-haspopup="menu"
|
| 663 |
+
onClick={(e) => openFrom(e.currentTarget)}
|
| 664 |
+
>
|
| 665 |
+
+ Create newβ¦
|
| 666 |
+
</button>
|
| 667 |
+
{at && (
|
| 668 |
+
<MenuShell at={at} menuRef={ref}>
|
| 669 |
+
<button
|
| 670 |
+
type="button"
|
| 671 |
+
className="shell-navmenu-item"
|
| 672 |
+
onClick={() => {
|
| 673 |
+
close();
|
| 674 |
+
onNewDatabase();
|
| 675 |
+
}}
|
| 676 |
+
>
|
| 677 |
+
New database
|
| 678 |
+
</button>
|
| 679 |
+
<button
|
| 680 |
+
type="button"
|
| 681 |
+
className="shell-navmenu-item"
|
| 682 |
+
onClick={() => {
|
| 683 |
+
close();
|
| 684 |
+
onFromTemplate();
|
| 685 |
+
}}
|
| 686 |
+
>
|
| 687 |
+
From a template
|
| 688 |
+
</button>
|
| 689 |
+
{canFolder ? (
|
| 690 |
+
<button
|
| 691 |
+
type="button"
|
| 692 |
+
className="shell-navmenu-item"
|
| 693 |
+
onClick={() => {
|
| 694 |
+
close();
|
| 695 |
+
setNaming(true);
|
| 696 |
+
}}
|
| 697 |
+
>
|
| 698 |
+
New folder
|
| 699 |
+
</button>
|
| 700 |
+
) : null}
|
| 701 |
+
</MenuShell>
|
| 702 |
+
)}
|
| 703 |
+
</>
|
| 704 |
+
);
|
| 705 |
+
}
|
| 706 |
+
return (
|
| 707 |
+
<form
|
| 708 |
+
className="shell-navmenu-form shell-newfolder-form"
|
| 709 |
+
onSubmit={(e) => {
|
| 710 |
+
e.preventDefault();
|
| 711 |
+
submit();
|
| 712 |
+
}}
|
| 713 |
+
>
|
| 714 |
+
<input
|
| 715 |
+
className="shell-navmenu-input"
|
| 716 |
+
autoFocus
|
| 717 |
+
maxLength={40}
|
| 718 |
+
placeholder="Folder name"
|
| 719 |
+
value={name}
|
| 720 |
+
onChange={(e) => setName(e.target.value)}
|
| 721 |
+
onKeyDown={(e) => {
|
| 722 |
+
if (e.key === "Escape") {
|
| 723 |
+
setName("");
|
| 724 |
+
setNaming(false);
|
| 725 |
+
}
|
| 726 |
+
}}
|
| 727 |
+
/>
|
| 728 |
+
<button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| 729 |
+
Add
|
| 730 |
+
</button>
|
| 731 |
+
</form>
|
| 732 |
+
);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
export function FolderHead({
|
| 736 |
+
folder,
|
| 737 |
+
count,
|
| 738 |
+
open,
|
| 739 |
+
collapsed,
|
| 740 |
+
onToggle,
|
| 741 |
+
onRename,
|
| 742 |
+
onDelete,
|
| 743 |
+
isDrop,
|
| 744 |
+
dropProps,
|
| 745 |
+
}: {
|
| 746 |
+
folder: NavFolder;
|
| 747 |
+
count: number;
|
| 748 |
+
/* β WAVE 35 Β· T07 (owner item 9) β the `important?: NavImportant` prop is DELETED. It carried
|
| 749 |
+
wave-34 R1's per-folder SUM of mark-important numbers; item 9 retires that badge, and `tsc`'s
|
| 750 |
+
unused-locals check is what named this prop the moment its only reader went. */
|
| 751 |
+
open: boolean;
|
| 752 |
+
collapsed: boolean;
|
| 753 |
+
onToggle: () => void;
|
| 754 |
+
onRename: (name: string) => void;
|
| 755 |
+
onDelete: () => void;
|
| 756 |
+
/** Wave 14 C-NAVFOLD β a database row is being dragged over this folder. */
|
| 757 |
+
isDrop?: boolean;
|
| 758 |
+
/** dragover/drop handlers, owned by the Shell (it holds the placement writer). */
|
| 759 |
+
dropProps?: React.HTMLAttributes<HTMLDivElement>;
|
| 760 |
+
}) {
|
| 761 |
+
const { at, ref, openFrom, close } = useMenu();
|
| 762 |
+
const [renaming, setRenaming] = useState(false);
|
| 763 |
+
const [name, setName] = useState(folder.name);
|
| 764 |
+
const submit = () => {
|
| 765 |
+
const clean = name.trim().replace(/\s+/g, " ");
|
| 766 |
+
if (clean && clean !== folder.name) onRename(clean);
|
| 767 |
+
setRenaming(false);
|
| 768 |
+
close();
|
| 769 |
+
};
|
| 770 |
+
if (collapsed) {
|
| 771 |
+
// The folded rail has no room for folder chrome; members render flat and the
|
| 772 |
+
// folder simply waits for the rail to open again.
|
| 773 |
+
return null;
|
| 774 |
+
}
|
| 775 |
+
return (
|
| 776 |
+
<div
|
| 777 |
+
className={"shell-nav-folder" + (isDrop ? " is-drop" : "")}
|
| 778 |
+
{...(dropProps ?? {})}
|
| 779 |
+
>
|
| 780 |
+
{/* R3: no chevron β the Views-rail folder look. Open state reads from the members
|
| 781 |
+
below it (and aria-expanded says it aloud); the mark + bold label ARE the row. */}
|
| 782 |
+
<button
|
| 783 |
+
type="button"
|
| 784 |
+
className="shell-nav-folderbtn"
|
| 785 |
+
aria-expanded={open}
|
| 786 |
+
onClick={onToggle}
|
| 787 |
+
>
|
| 788 |
+
<FolderIcon />
|
| 789 |
+
<span className="shell-nav-label">{folder.name}</span>
|
| 790 |
+
<span className="shell-nav-foldercount">{count}</span>
|
| 791 |
+
{/* ββ WAVE 35 Β· T07 (owner item 9, R4) β THE IMPORTANT SUM IS GONE FROM HERE.
|
| 792 |
+
Owner: remove the count *"anywhere else but the View itself"*. Wave 34's R1/C1 put a
|
| 793 |
+
sum of marked RECORDS beside this folder row; item 9 is later and retires it. The star
|
| 794 |
+
count now reaches the user through Home and the Starred module instead (W35-T16/T17).
|
| 795 |
+
β `count` STAYS, and the distinction is the whole reason this note is here rather than
|
| 796 |
+
the row simply being deleted: `count` is how many BLOCKS the folder holds and has meant
|
| 797 |
+
that since wave 14 β it is not a star count and owner item 9 does not name it. Deleting
|
| 798 |
+
it would retire an unrelated wave-14 feature under cover of this ruling
|
| 799 |
+
(PRD amendment A2). */}
|
| 800 |
+
</button>
|
| 801 |
+
<button
|
| 802 |
+
type="button"
|
| 803 |
+
className="shell-dots"
|
| 804 |
+
aria-label={`Options for folder ${folder.name}`}
|
| 805 |
+
aria-haspopup="menu"
|
| 806 |
+
onClick={(e) => {
|
| 807 |
+
e.stopPropagation();
|
| 808 |
+
setName(folder.name);
|
| 809 |
+
setRenaming(false);
|
| 810 |
+
openFrom(e.currentTarget);
|
| 811 |
+
}}
|
| 812 |
+
>
|
| 813 |
+
<DotsIcon />
|
| 814 |
+
</button>
|
| 815 |
+
{at && (
|
| 816 |
+
<MenuShell at={at} menuRef={ref}>
|
| 817 |
+
{renaming ? (
|
| 818 |
+
<form
|
| 819 |
+
className="shell-navmenu-form"
|
| 820 |
+
onSubmit={(e) => {
|
| 821 |
+
e.preventDefault();
|
| 822 |
+
submit();
|
| 823 |
+
}}
|
| 824 |
+
>
|
| 825 |
+
<input
|
| 826 |
+
className="shell-navmenu-input"
|
| 827 |
+
autoFocus
|
| 828 |
+
maxLength={40}
|
| 829 |
+
value={name}
|
| 830 |
+
onChange={(e) => setName(e.target.value)}
|
| 831 |
+
/>
|
| 832 |
+
<button type="submit" className="shell-navmenu-go" disabled={!name.trim()}>
|
| 833 |
+
Save
|
| 834 |
+
</button>
|
| 835 |
+
</form>
|
| 836 |
+
) : (
|
| 837 |
+
<button
|
| 838 |
+
type="button"
|
| 839 |
+
className="shell-navmenu-item"
|
| 840 |
+
onClick={() => setRenaming(true)}
|
| 841 |
+
>
|
| 842 |
+
Rename folder
|
| 843 |
+
</button>
|
| 844 |
+
)}
|
| 845 |
+
<button
|
| 846 |
+
type="button"
|
| 847 |
+
className="shell-navmenu-item is-danger"
|
| 848 |
+
onClick={() => {
|
| 849 |
+
close();
|
| 850 |
+
onDelete();
|
| 851 |
+
}}
|
| 852 |
+
>
|
| 853 |
+
Delete folder
|
| 854 |
+
</button>
|
| 855 |
+
</MenuShell>
|
| 856 |
+
)}
|
| 857 |
+
</div>
|
| 858 |
+
);
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
export function SchemaDrawer({
|
| 862 |
+
schemaKey,
|
| 863 |
+
onClose,
|
| 864 |
+
}: {
|
| 865 |
+
schemaKey: string;
|
| 866 |
+
onClose: () => void;
|
| 867 |
+
}) {
|
| 868 |
+
const [state, setState] = useState<
|
| 869 |
+
{ phase: "loading" } | { phase: "ready"; schema: SchemaPayload } | { phase: "error" }
|
| 870 |
+
>({ phase: "loading" });
|
| 871 |
+
useEffect(() => {
|
| 872 |
+
let cancelled = false;
|
| 873 |
+
setState({ phase: "loading" });
|
| 874 |
+
fetchSchema(schemaKey).then((schema) => {
|
| 875 |
+
if (cancelled) return;
|
| 876 |
+
setState(schema ? { phase: "ready", schema } : { phase: "error" });
|
| 877 |
+
});
|
| 878 |
+
return () => {
|
| 879 |
+
cancelled = true;
|
| 880 |
+
};
|
| 881 |
+
}, [schemaKey]);
|
| 882 |
+
useEffect(() => {
|
| 883 |
+
const onKey = (e: KeyboardEvent) => {
|
| 884 |
+
if (e.key === "Escape") onClose();
|
| 885 |
+
};
|
| 886 |
+
document.addEventListener("keydown", onKey, true);
|
| 887 |
+
return () => document.removeEventListener("keydown", onKey, true);
|
| 888 |
+
}, [onClose]);
|
| 889 |
+
return (
|
| 890 |
+
<div className="shell-schema-backdrop" onMouseDown={onClose}>
|
| 891 |
+
<aside
|
| 892 |
+
className="shell-schema"
|
| 893 |
+
role="dialog"
|
| 894 |
+
aria-label="Database schema"
|
| 895 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 896 |
+
>
|
| 897 |
+
{/* wave17 item 3 (R6) β the drawer opens on a mark, not on a sentence. */}
|
| 898 |
+
{state.phase === "loading" && (
|
| 899 |
+
<div className="shell-schema-empty is-spin">
|
| 900 |
+
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 901 |
+
</div>
|
| 902 |
+
)}
|
| 903 |
+
{state.phase === "error" && (
|
| 904 |
+
<div className="shell-schema-empty">
|
| 905 |
+
The schema could not be loaded. Close this panel and try again.
|
| 906 |
+
</div>
|
| 907 |
+
)}
|
| 908 |
+
{state.phase === "ready" && (
|
| 909 |
+
<>
|
| 910 |
+
<header className="shell-schema-head">
|
| 911 |
+
<div>
|
| 912 |
+
<div className="shell-schema-title">{state.schema.label}</div>
|
| 913 |
+
{state.schema.source && (
|
| 914 |
+
<div className="shell-schema-sub">Source Β· {state.schema.source}</div>
|
| 915 |
+
)}
|
| 916 |
+
</div>
|
| 917 |
+
<button
|
| 918 |
+
type="button"
|
| 919 |
+
className="shell-schema-close"
|
| 920 |
+
aria-label="Close schema"
|
| 921 |
+
onClick={onClose}
|
| 922 |
+
>
|
| 923 |
+
Γ
|
| 924 |
+
</button>
|
| 925 |
+
</header>
|
| 926 |
+
<div className="shell-schema-body">
|
| 927 |
+
{state.schema.note && (
|
| 928 |
+
<div className="shell-schema-note">{state.schema.note}</div>
|
| 929 |
+
)}
|
| 930 |
+
{state.schema.fields.length > 0 && (
|
| 931 |
+
<>
|
| 932 |
+
<div className="shell-schema-kicker">
|
| 933 |
+
Fields Β· {state.schema.fields.length}
|
| 934 |
+
</div>
|
| 935 |
+
<table className="shell-schema-table">
|
| 936 |
+
<thead>
|
| 937 |
+
<tr>
|
| 938 |
+
<th>Field</th>
|
| 939 |
+
<th>Type</th>
|
| 940 |
+
<th>Source</th>
|
| 941 |
+
</tr>
|
| 942 |
+
</thead>
|
| 943 |
+
<tbody>
|
| 944 |
+
{state.schema.fields.map((f) => (
|
| 945 |
+
<tr key={f.key}>
|
| 946 |
+
<td>
|
| 947 |
+
<div className="shell-schema-fname">{f.label}</div>
|
| 948 |
+
{f.description && (
|
| 949 |
+
<div className="shell-schema-fdesc">{f.description}</div>
|
| 950 |
+
)}
|
| 951 |
+
{f.options && (
|
| 952 |
+
<div className="shell-schema-fdesc">
|
| 953 |
+
Choices: {f.options.join(" Β· ")}
|
| 954 |
+
</div>
|
| 955 |
+
)}
|
| 956 |
+
</td>
|
| 957 |
+
<td className="shell-schema-type">{f.type}</td>
|
| 958 |
+
<td className="shell-schema-type">{f.source}</td>
|
| 959 |
+
</tr>
|
| 960 |
+
))}
|
| 961 |
+
</tbody>
|
| 962 |
+
</table>
|
| 963 |
+
</>
|
| 964 |
+
)}
|
| 965 |
+
{state.schema.measures.length > 0 && (
|
| 966 |
+
<>
|
| 967 |
+
<div className="shell-schema-kicker">
|
| 968 |
+
Semantic measures Β· {state.schema.measures.length}
|
| 969 |
+
</div>
|
| 970 |
+
<div className="shell-schema-measures">
|
| 971 |
+
{state.schema.measures.map((m) => (
|
| 972 |
+
<div key={m.key} className="shell-schema-measure">
|
| 973 |
+
<span className="shell-schema-fname">{m.label}</span>
|
| 974 |
+
<span className="shell-schema-type">{m.type}</span>
|
| 975 |
+
</div>
|
| 976 |
+
))}
|
| 977 |
+
</div>
|
| 978 |
+
</>
|
| 979 |
+
)}
|
| 980 |
+
</div>
|
| 981 |
+
</>
|
| 982 |
+
)}
|
| 983 |
+
</aside>
|
| 984 |
+
</div>
|
| 985 |
+
);
|
| 986 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/shell/Shell.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/shell/nav.ts
CHANGED
|
@@ -1148,12 +1148,50 @@ export const QUERY_ROUTE = "query";
|
|
| 1148 |
* layout-jump report, which is what that ticket was.
|
| 1149 |
*/
|
| 1150 |
export const ASSISTANT_ROUTE = "assistant";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1151 |
export const CHROME_ROUTES: ReadonlySet<string> = new Set([
|
| 1152 |
HOME_ROUTE,
|
| 1153 |
CONNECTORS_ROUTE,
|
| 1154 |
INBOX_ROUTE,
|
| 1155 |
QUERY_ROUTE,
|
| 1156 |
ASSISTANT_ROUTE,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1157 |
]);
|
| 1158 |
|
| 1159 |
/**
|
|
|
|
| 1148 |
* layout-jump report, which is what that ticket was.
|
| 1149 |
*/
|
| 1150 |
export const ASSISTANT_ROUTE = "assistant";
|
| 1151 |
+
|
| 1152 |
+
/**
|
| 1153 |
+
* ββ WAVE 35 Β· T06 (owner item 13, contract C6) β the three ACCOUNT routes. Owner: *"add below
|
| 1154 |
+
* settings the following button: Feedback β¦, Usage credits β¦, and a Subscription module β¦
|
| 1155 |
+
* Specialized for the Royal Imports tenant, do not show it."*
|
| 1156 |
+
*
|
| 1157 |
+
* β THEY MUST JOIN `CHROME_ROUTES` OR THEY RENDER NOTHING BEHIND A 200. Shell's oldest law denies
|
| 1158 |
+
* an undeclared SURFACE; these are chrome β they need no grant because they show the caller its own
|
| 1159 |
+
* account, never a tenant's data.
|
| 1160 |
+
* β `SUBSCRIPTION_ROUTE` is chrome for EVERY tenant here and refused for Royal Imports at the
|
| 1161 |
+
* DISPATCH (C6). Excluding it from this set instead would deny it to everyone, which is a different
|
| 1162 |
+
* bug wearing the same screen.
|
| 1163 |
+
*/
|
| 1164 |
+
/**
|
| 1165 |
+
* ββ WAVE 35 Β· T09 (owner item 8, R4) β Starred. Chrome, for the same reason Home is: the page
|
| 1166 |
+
* lists only objects the caller's own nav already granted, so it renders nothing the server did not
|
| 1167 |
+
* already send. A grant wall here would be a second opinion about a set the server already decided.
|
| 1168 |
+
*/
|
| 1169 |
+
export const STARRED_ROUTE = "starred";
|
| 1170 |
+
|
| 1171 |
+
export const FEEDBACK_ROUTE = "feedback";
|
| 1172 |
+
export const USAGE_ROUTE = "usage";
|
| 1173 |
+
export const SUBSCRIPTION_ROUTE = "subscription";
|
| 1174 |
+
|
| 1175 |
+
/**
|
| 1176 |
+
* β WAVE 35 Β· T06 β the tenant that does NOT get a subscription page. Owner item 13, verbatim:
|
| 1177 |
+
* *"Specialized for the Royal Imports tenant, do not show it."* Tenant #0 is the owner's own
|
| 1178 |
+
* company and is not a paying customer of the platform.
|
| 1179 |
+
* β ONE constant, read by BOTH halves of C6 (the menu row and the route), because "hidden in the
|
| 1180 |
+
* menu while the route still answers" is not hidden β and two copies of a slug is how those two
|
| 1181 |
+
* halves drift apart.
|
| 1182 |
+
*/
|
| 1183 |
+
export const NO_SUBSCRIPTION_TENANT = "royal-imports";
|
| 1184 |
+
|
| 1185 |
export const CHROME_ROUTES: ReadonlySet<string> = new Set([
|
| 1186 |
HOME_ROUTE,
|
| 1187 |
CONNECTORS_ROUTE,
|
| 1188 |
INBOX_ROUTE,
|
| 1189 |
QUERY_ROUTE,
|
| 1190 |
ASSISTANT_ROUTE,
|
| 1191 |
+
STARRED_ROUTE,
|
| 1192 |
+
FEEDBACK_ROUTE,
|
| 1193 |
+
USAGE_ROUTE,
|
| 1194 |
+
SUBSCRIPTION_ROUTE,
|
| 1195 |
]);
|
| 1196 |
|
| 1197 |
/**
|
web/src/shell/navExtras.css
CHANGED
|
@@ -1,704 +1,676 @@
|
|
| 1 |
-
/* shell/navExtras.css β C-SCHEMA (2026-08-02): nav folders, three-dots, schema drawer.
|
| 2 |
-
New classes only; the shell's own rail classes stay in index.css untouched. */
|
| 3 |
-
|
| 4 |
-
.shell-nav-row {
|
| 5 |
-
position: relative;
|
| 6 |
-
display: flex;
|
| 7 |
-
align-items: stretch;
|
| 8 |
-
min-width: 0;
|
| 9 |
-
/* wave17 SHELL (item 12) β the row is the painted surface now, so it needs
|
| 10 |
-
the shape the `<a>` used to draw. `--lp-r-sm` and not the Views rail's
|
| 11 |
-
`--lp-r-md`: the tint must land exactly where the item's hover pill lands
|
| 12 |
-
today, and every other pill in this rail is 4px. Item 12 asks the highlight
|
| 13 |
-
to reach the β―; it does not ask the nav to change shape. */
|
| 14 |
-
border-radius: var(--lp-r-sm);
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
/* The ROW carries hover and current-state, exactly as `.cg-view-row` does in
|
| 18 |
-
the Views rail β which is the whole of item 12. Hovering the three-dots now
|
| 19 |
-
lights the row it belongs to instead of leaving the link beside it cold. */
|
| 20 |
-
.shell-nav-row:hover {
|
| 21 |
-
background: var(--lp-surface-2);
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
.shell-nav-row.is-active {
|
| 25 |
-
background: var(--lp-blue-tint);
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
.shell-nav-row > .shell-nav-item {
|
| 29 |
-
flex: 1 1 auto;
|
| 30 |
-
min-width: 0;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
/* β LOAD-BEARING, not tidiness. `.shell-nav-item:hover` still paints its own
|
| 34 |
-
grey β it must, because the AI-assistant row is a bare `.shell-nav-item`
|
| 35 |
-
with no row around it. Left alone inside a row it would paint that grey
|
| 36 |
-
OVER the blue of the current database the moment the pointer crossed the
|
| 37 |
-
label: the link half would go grey while the β― half stayed blue. The child
|
| 38 |
-
selector outranks it (0,3,0 over 0,2,0), so this holds no matter which
|
| 39 |
-
stylesheet the bundler emits first β the nav has lost a rule to source
|
| 40 |
-
order before ([[loopable-nav-logo-toggle]]) and specificity is the fix that
|
| 41 |
-
does not depend on being lucky. */
|
| 42 |
-
.shell-nav-row > .shell-nav-item:hover {
|
| 43 |
-
background: transparent;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
.shell-nav-item.is-rail-skeleton {
|
| 47 |
-
cursor: default;
|
| 48 |
-
pointer-events: none;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
/* ββ W33-T75 (owner item 1) β THE RAIL'S ONE IN-FLIGHT STATE.
|
| 52 |
-
β THE POINT IS THE `:not()`, NOT THE BARS. While `/nav` is in flight EVERY real row is
|
| 53 |
-
hidden, so the rail cannot show five finished rows beside two loading ones β which is what
|
| 54 |
-
`reference/ERROR 6.png` is a picture of, and what "the navigation loads separately" has meant
|
| 55 |
-
in all four reports. They are hidden rather than unmounted because the database flyout portal,
|
| 56 |
-
the tooltips and the account menu hang off this subtree (see Shell.tsx's note).
|
| 57 |
-
β Geometry is copied from `.shell-nav-item` in index.css and must stay copied: 34 px min-height,
|
| 58 |
-
8 px gap, 7/8 padding, a 16 px mark. A skeleton row of a different height is the layout jump
|
| 59 |
-
this whole ticket is about, one step earlier. */
|
| 60 |
-
.shell-nav.is-rail-loading > *:not(.shell-rail-skeleton) {
|
| 61 |
-
display: none;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
/* β WAVE 34 R15 β "then a subtle line separation". SUBTLE is the whole specification, so this
|
| 65 |
-
is a hairline at the rail's own border token and nothing else: no margin collapse games, no
|
| 66 |
-
inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
|
| 67 |
-
β It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
|
| 68 |
-
hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
|
| 69 |
-
the rows one level deeper and the `> *` in that selector would stop reaching them, silently
|
| 70 |
-
bringing back the two-row-states defect the whole rule exists to prevent. */
|
| 71 |
-
.shell-nav-sep {
|
| 72 |
-
height: 1px;
|
| 73 |
-
margin: 6px 8px;
|
| 74 |
-
background: var(--lp-line);
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
.shell-rail-skeleton {
|
| 78 |
-
display: flex;
|
| 79 |
-
flex-direction: column;
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
.shell-rail-skeleton-row {
|
| 83 |
-
display: flex;
|
| 84 |
-
align-items: center;
|
| 85 |
-
gap: 8px;
|
| 86 |
-
min-height: 34px;
|
| 87 |
-
padding: 7px 8px;
|
| 88 |
-
box-sizing: border-box;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.shell-rail-skeleton-mark,
|
| 92 |
-
.shell-rail-skeleton-bar {
|
| 93 |
-
/* One flat tone, no shimmer: an animated sweep is a second thing moving in a rail whose whole
|
| 94 |
-
complaint is movement, and `prefers-reduced-motion` would then need a third state. */
|
| 95 |
-
background: var(--lp-surface-2);
|
| 96 |
-
border-radius: var(--lp-r-sm);
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
.shell-rail-skeleton-mark {
|
| 100 |
-
flex: 0 0 auto;
|
| 101 |
-
width: 16px;
|
| 102 |
-
height: 16px;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
.shell-rail-skeleton-bar {
|
| 106 |
-
flex: 0 0 auto;
|
| 107 |
-
width: 84px;
|
| 108 |
-
height: 9px;
|
| 109 |
-
border-radius: 4px;
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
/* Collapsed, the labels are gone from the real rail, so the skeleton drops its bars too β
|
| 113 |
-
otherwise the fold animation runs against a width the settled rail never has. */
|
| 114 |
-
.shell-side.is-collapsed .shell-rail-skeleton-bar {
|
| 115 |
-
display: none;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
.shell-nav-row > .shell-dots,
|
| 119 |
-
.shell-nav-folder > .shell-dots {
|
| 120 |
-
flex: 0 0 auto;
|
| 121 |
-
width: 24px;
|
| 122 |
-
border: 0;
|
| 123 |
-
padding: 0;
|
| 124 |
-
margin: 2px 2px 2px 0;
|
| 125 |
-
border-radius: 6px;
|
| 126 |
-
background: transparent;
|
| 127 |
-
color: var(--lp-muted);
|
| 128 |
-
cursor: pointer;
|
| 129 |
-
opacity: 0;
|
| 130 |
-
display: grid;
|
| 131 |
-
place-items: center;
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
.shell-nav-row:hover > .shell-dots,
|
| 135 |
-
.shell-nav-row:focus-within > .shell-dots,
|
| 136 |
-
.shell-nav-folder:hover > .shell-dots,
|
| 137 |
-
.shell-nav-folder:focus-within > .shell-dots {
|
| 138 |
-
opacity: 1;
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
.shell-nav-row > .shell-dots:hover,
|
| 142 |
-
.shell-nav-folder > .shell-dots:hover {
|
| 143 |
-
background: var(--lp-wash);
|
| 144 |
-
color: var(--lp-ink);
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
.shell-dots-icon {
|
| 148 |
-
width: 14px;
|
| 149 |
-
height: 14px;
|
| 150 |
-
fill: currentColor;
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
.shell-nav-row.is-foldered > .shell-nav-item {
|
| 154 |
-
padding-left: 30px;
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
/* ββ folder heads βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 158 |
-
|
| 159 |
-
.shell-nav-folder {
|
| 160 |
-
display: flex;
|
| 161 |
-
align-items: stretch;
|
| 162 |
-
min-width: 0;
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
.shell-nav-folderbtn {
|
| 166 |
-
flex: 1 1 auto;
|
| 167 |
-
min-width: 0;
|
| 168 |
-
display: flex;
|
| 169 |
-
align-items: center;
|
| 170 |
-
gap: 7px;
|
| 171 |
-
border: 0;
|
| 172 |
-
background: transparent;
|
| 173 |
-
padding: 6px 8px;
|
| 174 |
-
border-radius: 7px;
|
| 175 |
-
color: var(--lp-ink);
|
| 176 |
-
font: inherit;
|
| 177 |
-
font-size: var(--lp-fs-xs);
|
| 178 |
-
/* Wave 14 R3 β the Views-rail folder look: BOLD label, no chevron. */
|
| 179 |
-
font-weight: 600;
|
| 180 |
-
cursor: pointer;
|
| 181 |
-
text-align: left;
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
.shell-nav-folderbtn:hover {
|
| 185 |
-
background: var(--lp-wash);
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
.shell-nav-foldercount {
|
| 189 |
-
margin-left: auto;
|
| 190 |
-
/* Wave 14 item 7 β the count wears the LABEL's size, muted; nothing "all over the
|
| 191 |
-
place with regards to symmetry".
|
| 192 |
-
β wave17 SHELL (item 11b) SUPERSEDES THE SIZE HALF. Wave 14 matched the count
|
| 193 |
-
to the label so the row would not look ragged; at the label's own size, next
|
| 194 |
-
to a 600-weight name, it stopped reading as an annotation and started reading
|
| 195 |
-
as a second piece of the title. Smaller is what makes it recede.
|
| 196 |
-
The COLOUR TOKEN IS UNCHANGED on purpose: `--lp-muted` already IS the grey,
|
| 197 |
-
and the same owner item asks the Views rail's `cg-fold-count` for the same
|
| 198 |
-
treatment. A lighter grey invented here would need a literal that has nowhere
|
| 199 |
-
legal to live, and would put the two rails' counts a shade apart β which is
|
| 200 |
-
the exact symmetry complaint wave 14 was answering. Shrinking the type does
|
| 201 |
-
the lightening. */
|
| 202 |
-
font-size: var(--lp-fs-3xs);
|
| 203 |
-
font-weight: 400;
|
| 204 |
-
color: var(--lp-muted);
|
| 205 |
-
/* Counts line up column-wise as folders open and close, in both rails. */
|
| 206 |
-
font-variant-numeric: tabular-nums;
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
/*
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
.shell-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
width:
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
.shell-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
-
.shell-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
/*
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
.
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
.shell-navmenu-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
padding:
|
| 390 |
-
font:
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
.shell-navmenu-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
.
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
}
|
| 451 |
-
|
| 452 |
-
.shell-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
font-size: var(--lp-fs-xs);
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
.
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
padding:
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
.shell-navconfirm-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
}
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
.shell-schema-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
.shell-schema-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
color: var(--lp-muted);
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
padding:
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
}
|
| 645 |
-
|
| 646 |
-
.shell-schema-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
}
|
| 667 |
-
|
| 668 |
-
.shell-schema-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
.shell-schema-fdesc {
|
| 679 |
-
margin-top: 2px;
|
| 680 |
-
font-size: var(--lp-fs-2xs);
|
| 681 |
-
color: var(--lp-muted);
|
| 682 |
-
line-height: 1.4;
|
| 683 |
-
}
|
| 684 |
-
|
| 685 |
-
.shell-schema-type {
|
| 686 |
-
white-space: nowrap;
|
| 687 |
-
color: var(--lp-muted);
|
| 688 |
-
font-size: var(--lp-fs-2xs);
|
| 689 |
-
}
|
| 690 |
-
|
| 691 |
-
.shell-schema-measures {
|
| 692 |
-
display: flex;
|
| 693 |
-
flex-direction: column;
|
| 694 |
-
}
|
| 695 |
-
|
| 696 |
-
.shell-schema-measure {
|
| 697 |
-
display: flex;
|
| 698 |
-
align-items: baseline;
|
| 699 |
-
justify-content: space-between;
|
| 700 |
-
gap: 10px;
|
| 701 |
-
padding: 5px 0;
|
| 702 |
-
border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
|
| 703 |
-
font-size: var(--lp-fs-xs);
|
| 704 |
-
}
|
|
|
|
| 1 |
+
/* shell/navExtras.css β C-SCHEMA (2026-08-02): nav folders, three-dots, schema drawer.
|
| 2 |
+
New classes only; the shell's own rail classes stay in index.css untouched. */
|
| 3 |
+
|
| 4 |
+
.shell-nav-row {
|
| 5 |
+
position: relative;
|
| 6 |
+
display: flex;
|
| 7 |
+
align-items: stretch;
|
| 8 |
+
min-width: 0;
|
| 9 |
+
/* wave17 SHELL (item 12) β the row is the painted surface now, so it needs
|
| 10 |
+
the shape the `<a>` used to draw. `--lp-r-sm` and not the Views rail's
|
| 11 |
+
`--lp-r-md`: the tint must land exactly where the item's hover pill lands
|
| 12 |
+
today, and every other pill in this rail is 4px. Item 12 asks the highlight
|
| 13 |
+
to reach the β―; it does not ask the nav to change shape. */
|
| 14 |
+
border-radius: var(--lp-r-sm);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/* The ROW carries hover and current-state, exactly as `.cg-view-row` does in
|
| 18 |
+
the Views rail β which is the whole of item 12. Hovering the three-dots now
|
| 19 |
+
lights the row it belongs to instead of leaving the link beside it cold. */
|
| 20 |
+
.shell-nav-row:hover {
|
| 21 |
+
background: var(--lp-surface-2);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.shell-nav-row.is-active {
|
| 25 |
+
background: var(--lp-blue-tint);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.shell-nav-row > .shell-nav-item {
|
| 29 |
+
flex: 1 1 auto;
|
| 30 |
+
min-width: 0;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* β LOAD-BEARING, not tidiness. `.shell-nav-item:hover` still paints its own
|
| 34 |
+
grey β it must, because the AI-assistant row is a bare `.shell-nav-item`
|
| 35 |
+
with no row around it. Left alone inside a row it would paint that grey
|
| 36 |
+
OVER the blue of the current database the moment the pointer crossed the
|
| 37 |
+
label: the link half would go grey while the β― half stayed blue. The child
|
| 38 |
+
selector outranks it (0,3,0 over 0,2,0), so this holds no matter which
|
| 39 |
+
stylesheet the bundler emits first β the nav has lost a rule to source
|
| 40 |
+
order before ([[loopable-nav-logo-toggle]]) and specificity is the fix that
|
| 41 |
+
does not depend on being lucky. */
|
| 42 |
+
.shell-nav-row > .shell-nav-item:hover {
|
| 43 |
+
background: transparent;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.shell-nav-item.is-rail-skeleton {
|
| 47 |
+
cursor: default;
|
| 48 |
+
pointer-events: none;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* ββ W33-T75 (owner item 1) β THE RAIL'S ONE IN-FLIGHT STATE.
|
| 52 |
+
β THE POINT IS THE `:not()`, NOT THE BARS. While `/nav` is in flight EVERY real row is
|
| 53 |
+
hidden, so the rail cannot show five finished rows beside two loading ones β which is what
|
| 54 |
+
`reference/ERROR 6.png` is a picture of, and what "the navigation loads separately" has meant
|
| 55 |
+
in all four reports. They are hidden rather than unmounted because the database flyout portal,
|
| 56 |
+
the tooltips and the account menu hang off this subtree (see Shell.tsx's note).
|
| 57 |
+
β Geometry is copied from `.shell-nav-item` in index.css and must stay copied: 34 px min-height,
|
| 58 |
+
8 px gap, 7/8 padding, a 16 px mark. A skeleton row of a different height is the layout jump
|
| 59 |
+
this whole ticket is about, one step earlier. */
|
| 60 |
+
.shell-nav.is-rail-loading > *:not(.shell-rail-skeleton) {
|
| 61 |
+
display: none;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* β WAVE 34 R15 β "then a subtle line separation". SUBTLE is the whole specification, so this
|
| 65 |
+
is a hairline at the rail's own border token and nothing else: no margin collapse games, no
|
| 66 |
+
inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
|
| 67 |
+
β It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
|
| 68 |
+
hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
|
| 69 |
+
the rows one level deeper and the `> *` in that selector would stop reaching them, silently
|
| 70 |
+
bringing back the two-row-states defect the whole rule exists to prevent. */
|
| 71 |
+
.shell-nav-sep {
|
| 72 |
+
height: 1px;
|
| 73 |
+
margin: 6px 8px;
|
| 74 |
+
background: var(--lp-line);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.shell-rail-skeleton {
|
| 78 |
+
display: flex;
|
| 79 |
+
flex-direction: column;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.shell-rail-skeleton-row {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: center;
|
| 85 |
+
gap: 8px;
|
| 86 |
+
min-height: 34px;
|
| 87 |
+
padding: 7px 8px;
|
| 88 |
+
box-sizing: border-box;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.shell-rail-skeleton-mark,
|
| 92 |
+
.shell-rail-skeleton-bar {
|
| 93 |
+
/* One flat tone, no shimmer: an animated sweep is a second thing moving in a rail whose whole
|
| 94 |
+
complaint is movement, and `prefers-reduced-motion` would then need a third state. */
|
| 95 |
+
background: var(--lp-surface-2);
|
| 96 |
+
border-radius: var(--lp-r-sm);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.shell-rail-skeleton-mark {
|
| 100 |
+
flex: 0 0 auto;
|
| 101 |
+
width: 16px;
|
| 102 |
+
height: 16px;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.shell-rail-skeleton-bar {
|
| 106 |
+
flex: 0 0 auto;
|
| 107 |
+
width: 84px;
|
| 108 |
+
height: 9px;
|
| 109 |
+
border-radius: 4px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Collapsed, the labels are gone from the real rail, so the skeleton drops its bars too β
|
| 113 |
+
otherwise the fold animation runs against a width the settled rail never has. */
|
| 114 |
+
.shell-side.is-collapsed .shell-rail-skeleton-bar {
|
| 115 |
+
display: none;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.shell-nav-row > .shell-dots,
|
| 119 |
+
.shell-nav-folder > .shell-dots {
|
| 120 |
+
flex: 0 0 auto;
|
| 121 |
+
width: 24px;
|
| 122 |
+
border: 0;
|
| 123 |
+
padding: 0;
|
| 124 |
+
margin: 2px 2px 2px 0;
|
| 125 |
+
border-radius: 6px;
|
| 126 |
+
background: transparent;
|
| 127 |
+
color: var(--lp-muted);
|
| 128 |
+
cursor: pointer;
|
| 129 |
+
opacity: 0;
|
| 130 |
+
display: grid;
|
| 131 |
+
place-items: center;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.shell-nav-row:hover > .shell-dots,
|
| 135 |
+
.shell-nav-row:focus-within > .shell-dots,
|
| 136 |
+
.shell-nav-folder:hover > .shell-dots,
|
| 137 |
+
.shell-nav-folder:focus-within > .shell-dots {
|
| 138 |
+
opacity: 1;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.shell-nav-row > .shell-dots:hover,
|
| 142 |
+
.shell-nav-folder > .shell-dots:hover {
|
| 143 |
+
background: var(--lp-wash);
|
| 144 |
+
color: var(--lp-ink);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.shell-dots-icon {
|
| 148 |
+
width: 14px;
|
| 149 |
+
height: 14px;
|
| 150 |
+
fill: currentColor;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.shell-nav-row.is-foldered > .shell-nav-item {
|
| 154 |
+
padding-left: 30px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* ββ folder heads βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 158 |
+
|
| 159 |
+
.shell-nav-folder {
|
| 160 |
+
display: flex;
|
| 161 |
+
align-items: stretch;
|
| 162 |
+
min-width: 0;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.shell-nav-folderbtn {
|
| 166 |
+
flex: 1 1 auto;
|
| 167 |
+
min-width: 0;
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
gap: 7px;
|
| 171 |
+
border: 0;
|
| 172 |
+
background: transparent;
|
| 173 |
+
padding: 6px 8px;
|
| 174 |
+
border-radius: 7px;
|
| 175 |
+
color: var(--lp-ink);
|
| 176 |
+
font: inherit;
|
| 177 |
+
font-size: var(--lp-fs-xs);
|
| 178 |
+
/* Wave 14 R3 β the Views-rail folder look: BOLD label, no chevron. */
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
cursor: pointer;
|
| 181 |
+
text-align: left;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.shell-nav-folderbtn:hover {
|
| 185 |
+
background: var(--lp-wash);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.shell-nav-foldercount {
|
| 189 |
+
margin-left: auto;
|
| 190 |
+
/* Wave 14 item 7 β the count wears the LABEL's size, muted; nothing "all over the
|
| 191 |
+
place with regards to symmetry".
|
| 192 |
+
β wave17 SHELL (item 11b) SUPERSEDES THE SIZE HALF. Wave 14 matched the count
|
| 193 |
+
to the label so the row would not look ragged; at the label's own size, next
|
| 194 |
+
to a 600-weight name, it stopped reading as an annotation and started reading
|
| 195 |
+
as a second piece of the title. Smaller is what makes it recede.
|
| 196 |
+
The COLOUR TOKEN IS UNCHANGED on purpose: `--lp-muted` already IS the grey,
|
| 197 |
+
and the same owner item asks the Views rail's `cg-fold-count` for the same
|
| 198 |
+
treatment. A lighter grey invented here would need a literal that has nowhere
|
| 199 |
+
legal to live, and would put the two rails' counts a shade apart β which is
|
| 200 |
+
the exact symmetry complaint wave 14 was answering. Shrinking the type does
|
| 201 |
+
the lightening. */
|
| 202 |
+
font-size: var(--lp-fs-3xs);
|
| 203 |
+
font-weight: 400;
|
| 204 |
+
color: var(--lp-muted);
|
| 205 |
+
/* Counts line up column-wise as folders open and close, in both rails. */
|
| 206 |
+
font-variant-numeric: tabular-nums;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/* ββ WAVE 35 Β· T07 (owner item 9, R4) β `.shell-nav-count` AND ITS ADJACENCY RULE ARE DELETED.
|
| 210 |
+
They styled wave-34 R1's mark-important number in the database flyout and on a collapsed folder
|
| 211 |
+
head. Owner item 9 is later and explicit: the count belongs to the view itself and nowhere else;
|
| 212 |
+
the marked VIEWS reach the user through Home and the Starred module instead. Both elements are
|
| 213 |
+
gone from `Shell.tsx` and `NavExtras.tsx`, `/nav` stops computing the number at all (W35-T43),
|
| 214 |
+
and `verify_wiring`'s two W34-W1 rows are INVERTED into `DELETIONS` rather than removed.
|
| 215 |
+
β `.shell-nav-foldercount` SURVIVES and is a different thing: how many BLOCKS a folder holds,
|
| 216 |
+
since wave 14. Deleting it would retire an unrelated feature under cover of this ruling. */
|
| 217 |
+
|
| 218 |
+
/* ββ wave 14 C-NAVFOLD: drag placement (items 4/6) βββββββββββββββββββββββββ */
|
| 219 |
+
|
| 220 |
+
.shell-nav-row.is-dragging {
|
| 221 |
+
opacity: 0.45;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.shell-nav-folder.is-drop {
|
| 225 |
+
background: var(--lp-blue-tint);
|
| 226 |
+
border-radius: 7px;
|
| 227 |
+
box-shadow: inset 0 0 0 1px var(--lp-blue-deep);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.shell-nav-list.is-drop-root {
|
| 231 |
+
border-radius: 9px;
|
| 232 |
+
box-shadow: inset 0 0 0 1px var(--lp-line);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.shell-newfolder {
|
| 236 |
+
display: block;
|
| 237 |
+
width: 100%;
|
| 238 |
+
border: 0;
|
| 239 |
+
background: transparent;
|
| 240 |
+
padding: 6px 8px;
|
| 241 |
+
margin-top: 2px;
|
| 242 |
+
border-radius: 7px;
|
| 243 |
+
font: inherit;
|
| 244 |
+
font-size: var(--lp-fs-2xs);
|
| 245 |
+
color: var(--lp-muted);
|
| 246 |
+
text-align: left;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
white-space: nowrap;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.shell-newfolder:hover {
|
| 252 |
+
background: var(--lp-wash);
|
| 253 |
+
color: var(--lp-ink);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
/* WAVE 19 R11 β the same row in the folded rail: the "+" alone, centred in the
|
| 257 |
+
56px strip, opening the rail rather than a popover it has no room for. */
|
| 258 |
+
.shell-newfolder.is-collapsed {
|
| 259 |
+
display: grid;
|
| 260 |
+
place-items: center;
|
| 261 |
+
padding: 6px 0;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.shell-newthing-plus {
|
| 265 |
+
width: 16px;
|
| 266 |
+
height: 16px;
|
| 267 |
+
fill: none;
|
| 268 |
+
stroke: currentColor;
|
| 269 |
+
stroke-width: 1.5;
|
| 270 |
+
stroke-linecap: round;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.shell-newfolder-form {
|
| 274 |
+
padding: 4px 2px 2px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ββ the fixed popover menu βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 278 |
+
|
| 279 |
+
.shell-navmenu {
|
| 280 |
+
position: fixed;
|
| 281 |
+
z-index: 80;
|
| 282 |
+
min-width: 196px;
|
| 283 |
+
max-width: 240px;
|
| 284 |
+
padding: 5px;
|
| 285 |
+
border: 1px solid var(--lp-line);
|
| 286 |
+
border-radius: 9px;
|
| 287 |
+
background: var(--lp-surface);
|
| 288 |
+
box-shadow: 0 10px 34px rgba(20, 30, 43, 0.16);
|
| 289 |
+
font-size: var(--lp-fs-xs);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.shell-navmenu-item {
|
| 293 |
+
display: flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
gap: 8px;
|
| 296 |
+
width: 100%;
|
| 297 |
+
border: 0;
|
| 298 |
+
background: transparent;
|
| 299 |
+
padding: 6px 8px;
|
| 300 |
+
border-radius: 6px;
|
| 301 |
+
font: inherit;
|
| 302 |
+
color: var(--lp-ink);
|
| 303 |
+
cursor: pointer;
|
| 304 |
+
text-align: left;
|
| 305 |
+
/* Wave 14 R7 β every menu action occupies ONE line, app-wide. */
|
| 306 |
+
white-space: nowrap;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.shell-navmenu-item:hover {
|
| 310 |
+
background: var(--lp-wash);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.shell-navmenu-item.is-current {
|
| 314 |
+
color: var(--lp-muted);
|
| 315 |
+
cursor: default;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.shell-navmenu-item.is-danger {
|
| 319 |
+
color: var(--lp-red-deep);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* A secondary action inside a picker, not a menu row in its own right. */
|
| 323 |
+
.shell-navmenu-item.is-quiet {
|
| 324 |
+
margin-top: 2px;
|
| 325 |
+
font-size: var(--lp-fs-3xs);
|
| 326 |
+
color: var(--lp-muted);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/* ββ WAVE 19 R8 / C1: the database icon picker βββββββββββββββββββββββββββββββ */
|
| 330 |
+
|
| 331 |
+
.shell-navmenu-pick {
|
| 332 |
+
padding: 4px 3px 3px;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* WRAPS on purpose: twelve 26px swatches is ~312px and the menu is capped at
|
| 336 |
+
240 (a popover wider than the rail it hangs off reads as a panel that has
|
| 337 |
+
come loose). Two rows of six is the shape that falls out of the cap, and the
|
| 338 |
+
tone row below it is five β so the block reads as "shape, then colour"
|
| 339 |
+
without a heading having to say so. */
|
| 340 |
+
.shell-navmenu-pickrow {
|
| 341 |
+
display: flex;
|
| 342 |
+
flex-wrap: wrap;
|
| 343 |
+
gap: 2px;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.shell-navmenu-pickrow + .shell-navmenu-pickrow {
|
| 347 |
+
margin-top: 4px;
|
| 348 |
+
padding-top: 5px;
|
| 349 |
+
border-top: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.shell-navmenu-swatch {
|
| 353 |
+
flex: 0 0 auto;
|
| 354 |
+
display: grid;
|
| 355 |
+
place-items: center;
|
| 356 |
+
width: 26px;
|
| 357 |
+
height: 26px;
|
| 358 |
+
border: 1px solid transparent;
|
| 359 |
+
border-radius: 6px;
|
| 360 |
+
background: transparent;
|
| 361 |
+
padding: 0;
|
| 362 |
+
font: inherit;
|
| 363 |
+
cursor: pointer;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.shell-navmenu-swatch:hover {
|
| 367 |
+
background: var(--lp-wash);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/* The current choice is marked by its OUTLINE, never by a fill: the swatch's
|
| 371 |
+
whole job is to show the mark in its real colours, and a tinted plate behind
|
| 372 |
+
a pastel glyph changes the thing it is previewing. */
|
| 373 |
+
.shell-navmenu-swatch.is-on {
|
| 374 |
+
border-color: var(--lp-blue-deep);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.shell-navmenu-swatch:focus-visible {
|
| 378 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 379 |
+
outline-offset: -2px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.shell-navmenu-tick {
|
| 383 |
+
margin-left: auto;
|
| 384 |
+
font-size: var(--lp-fs-3xs);
|
| 385 |
+
color: var(--lp-muted);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.shell-navmenu-kicker {
|
| 389 |
+
padding: 7px 8px 3px;
|
| 390 |
+
font-size: var(--lp-fs-3xs);
|
| 391 |
+
font-weight: 600;
|
| 392 |
+
color: var(--lp-muted);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.shell-navmenu-note {
|
| 396 |
+
padding: 4px 8px 6px;
|
| 397 |
+
font-size: var(--lp-fs-3xs);
|
| 398 |
+
color: var(--lp-muted);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.shell-navmenu-form {
|
| 402 |
+
display: flex;
|
| 403 |
+
gap: 5px;
|
| 404 |
+
padding: 5px 6px 6px;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.shell-navmenu-input {
|
| 408 |
+
flex: 1 1 auto;
|
| 409 |
+
min-width: 0;
|
| 410 |
+
border: 1px solid var(--lp-line);
|
| 411 |
+
border-radius: 6px;
|
| 412 |
+
padding: 4px 7px;
|
| 413 |
+
font: inherit;
|
| 414 |
+
font-size: var(--lp-fs-xs);
|
| 415 |
+
background: var(--lp-surface);
|
| 416 |
+
color: var(--lp-ink);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.shell-navmenu-input:focus {
|
| 420 |
+
outline: none;
|
| 421 |
+
border-color: var(--lp-blue-deep);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.shell-navmenu-go {
|
| 425 |
+
flex: 0 0 auto;
|
| 426 |
+
border: 1px solid var(--lp-line);
|
| 427 |
+
border-radius: 6px;
|
| 428 |
+
background: var(--lp-surface);
|
| 429 |
+
padding: 4px 9px;
|
| 430 |
+
font: inherit;
|
| 431 |
+
font-size: var(--lp-fs-xs);
|
| 432 |
+
color: var(--lp-ink);
|
| 433 |
+
cursor: pointer;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.shell-navmenu-go:disabled {
|
| 437 |
+
color: var(--lp-muted);
|
| 438 |
+
cursor: default;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* ββ WAVE 21 item 6 (R3 / C3): the delete-database confirm face βββββββββββββ
|
| 442 |
+
Wider than the action menu, and only while it is showing: the panel's job
|
| 443 |
+
changes from "pick one of four one-line verbs" to "read what is about to be
|
| 444 |
+
destroyed", and R7's one-line law is about MENU ROWS, not about prose. The
|
| 445 |
+
width is matched in `MenuShell`'s viewport clamp β a wider panel clamped at
|
| 446 |
+
the narrow number spills off the right edge on precisely the rows nearest
|
| 447 |
+
it. */
|
| 448 |
+
.shell-navmenu.is-wide {
|
| 449 |
+
max-width: 312px;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.shell-navconfirm {
|
| 453 |
+
padding: 3px 2px 2px;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.shell-navconfirm-h {
|
| 457 |
+
margin: 0;
|
| 458 |
+
padding: 5px 8px 2px;
|
| 459 |
+
font-size: var(--lp-fs-xs);
|
| 460 |
+
font-weight: 600;
|
| 461 |
+
color: var(--lp-ink);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/* The footprint. `disc` rather than the app's usual bare rows because these ARE
|
| 465 |
+
a list of things and reading them as one takes a mark β this is the one place
|
| 466 |
+
in the nav chrome where the user is counting. */
|
| 467 |
+
.shell-navconfirm-list {
|
| 468 |
+
margin: 0;
|
| 469 |
+
padding: 0 8px 4px 24px;
|
| 470 |
+
font-size: var(--lp-fs-3xs);
|
| 471 |
+
color: var(--lp-ink);
|
| 472 |
+
line-height: 1.55;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.shell-navconfirm-err {
|
| 476 |
+
margin: 0;
|
| 477 |
+
padding: 2px 8px 4px;
|
| 478 |
+
font-size: var(--lp-fs-3xs);
|
| 479 |
+
color: var(--lp-red-deep);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.shell-navconfirm-actions {
|
| 483 |
+
display: flex;
|
| 484 |
+
align-items: center;
|
| 485 |
+
gap: 6px;
|
| 486 |
+
padding: 4px 6px 4px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
/* β THE DESTRUCTIVE BUTTON IS FILLED, and the way back is the quiet one. The
|
| 490 |
+
opposite arrangement (a quiet Delete beside a filled Cancel) is the pattern
|
| 491 |
+
that makes people click twice: the eye lands on the filled control, and if
|
| 492 |
+
that control is the one that does nothing the second click goes to the one
|
| 493 |
+
that does. Weight follows CONSEQUENCE here, not safety β the safety is the
|
| 494 |
+
sentence above it. */
|
| 495 |
+
.shell-navconfirm-go {
|
| 496 |
+
flex: 0 0 auto;
|
| 497 |
+
border: 1px solid var(--lp-red-deep);
|
| 498 |
+
border-radius: 6px;
|
| 499 |
+
background: var(--lp-red-deep);
|
| 500 |
+
padding: 4px 11px;
|
| 501 |
+
font: inherit;
|
| 502 |
+
font-size: var(--lp-fs-xs);
|
| 503 |
+
color: var(--lp-surface);
|
| 504 |
+
cursor: pointer;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.shell-navconfirm-go:disabled {
|
| 508 |
+
border-color: var(--lp-line);
|
| 509 |
+
background: var(--lp-wash);
|
| 510 |
+
color: var(--lp-muted);
|
| 511 |
+
cursor: default;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.shell-navconfirm-actions .shell-navmenu-item.is-quiet {
|
| 515 |
+
width: auto;
|
| 516 |
+
margin-top: 0;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* ββ the schema drawer ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 520 |
+
|
| 521 |
+
.shell-schema-backdrop {
|
| 522 |
+
position: fixed;
|
| 523 |
+
inset: 0;
|
| 524 |
+
z-index: 90;
|
| 525 |
+
background: rgba(25, 34, 46, 0.28);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
.shell-schema {
|
| 529 |
+
position: absolute;
|
| 530 |
+
top: 0;
|
| 531 |
+
right: 0;
|
| 532 |
+
bottom: 0;
|
| 533 |
+
width: min(430px, 92vw);
|
| 534 |
+
display: flex;
|
| 535 |
+
flex-direction: column;
|
| 536 |
+
background: var(--lp-surface);
|
| 537 |
+
border-left: 1px solid var(--lp-line);
|
| 538 |
+
box-shadow: -18px 0 48px rgba(20, 30, 43, 0.16);
|
| 539 |
+
animation: shell-schema-in 160ms cubic-bezier(0.2, 0, 0, 1);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
@keyframes shell-schema-in {
|
| 543 |
+
from {
|
| 544 |
+
transform: translateX(14px);
|
| 545 |
+
opacity: 0;
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.shell-schema-head {
|
| 550 |
+
display: flex;
|
| 551 |
+
align-items: flex-start;
|
| 552 |
+
justify-content: space-between;
|
| 553 |
+
gap: 10px;
|
| 554 |
+
padding: 16px 18px 12px;
|
| 555 |
+
border-bottom: 1px solid var(--lp-line);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.shell-schema-title {
|
| 559 |
+
font-size: var(--lp-fs-md);
|
| 560 |
+
font-weight: 600;
|
| 561 |
+
color: var(--lp-ink);
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.shell-schema-sub {
|
| 565 |
+
margin-top: 2px;
|
| 566 |
+
font-size: var(--lp-fs-2xs);
|
| 567 |
+
color: var(--lp-muted);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.shell-schema-close {
|
| 571 |
+
border: 0;
|
| 572 |
+
background: transparent;
|
| 573 |
+
font-size: var(--lp-fs-md);
|
| 574 |
+
line-height: 1;
|
| 575 |
+
color: var(--lp-muted);
|
| 576 |
+
cursor: pointer;
|
| 577 |
+
padding: 2px 6px;
|
| 578 |
+
border-radius: 6px;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.shell-schema-close:hover {
|
| 582 |
+
background: var(--lp-wash);
|
| 583 |
+
color: var(--lp-ink);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.shell-schema-body {
|
| 587 |
+
flex: 1 1 auto;
|
| 588 |
+
min-height: 0;
|
| 589 |
+
overflow-y: auto;
|
| 590 |
+
padding: 12px 18px 22px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.shell-schema-empty {
|
| 594 |
+
padding: 26px 18px;
|
| 595 |
+
color: var(--lp-muted);
|
| 596 |
+
font-size: var(--lp-fs-xs);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
/* wave17 SHELL (item 3/R6) β the same box, carrying the mark instead of a
|
| 600 |
+
sentence. Centred, because a drawer that has not loaded yet has no left edge
|
| 601 |
+
of content for it to hang off. */
|
| 602 |
+
.shell-schema-empty.is-spin {
|
| 603 |
+
display: flex;
|
| 604 |
+
justify-content: center;
|
| 605 |
+
padding: 40px 18px;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.shell-schema-note {
|
| 609 |
+
padding: 9px 11px;
|
| 610 |
+
border: 1px solid var(--lp-line);
|
| 611 |
+
border-radius: 8px;
|
| 612 |
+
background: var(--lp-wash);
|
| 613 |
+
color: var(--lp-muted);
|
| 614 |
+
font-size: var(--lp-fs-xs);
|
| 615 |
+
margin-bottom: 12px;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.shell-schema-kicker {
|
| 619 |
+
margin: 14px 0 7px;
|
| 620 |
+
font-size: var(--lp-fs-2xs);
|
| 621 |
+
font-weight: 600;
|
| 622 |
+
color: var(--lp-muted);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
.shell-schema-table {
|
| 626 |
+
width: 100%;
|
| 627 |
+
border-collapse: collapse;
|
| 628 |
+
font-size: var(--lp-fs-xs);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.shell-schema-table th {
|
| 632 |
+
text-align: left;
|
| 633 |
+
font-weight: 600;
|
| 634 |
+
font-size: var(--lp-fs-3xs);
|
| 635 |
+
color: var(--lp-muted);
|
| 636 |
+
padding: 4px 8px 4px 0;
|
| 637 |
+
border-bottom: 1px solid var(--lp-line);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.shell-schema-table td {
|
| 641 |
+
vertical-align: top;
|
| 642 |
+
padding: 7px 8px 7px 0;
|
| 643 |
+
border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.shell-schema-fname {
|
| 647 |
+
color: var(--lp-ink);
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.shell-schema-fdesc {
|
| 651 |
+
margin-top: 2px;
|
| 652 |
+
font-size: var(--lp-fs-2xs);
|
| 653 |
+
color: var(--lp-muted);
|
| 654 |
+
line-height: 1.4;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.shell-schema-type {
|
| 658 |
+
white-space: nowrap;
|
| 659 |
+
color: var(--lp-muted);
|
| 660 |
+
font-size: var(--lp-fs-2xs);
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.shell-schema-measures {
|
| 664 |
+
display: flex;
|
| 665 |
+
flex-direction: column;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.shell-schema-measure {
|
| 669 |
+
display: flex;
|
| 670 |
+
align-items: baseline;
|
| 671 |
+
justify-content: space-between;
|
| 672 |
+
gap: 10px;
|
| 673 |
+
padding: 5px 0;
|
| 674 |
+
border-bottom: 1px solid color-mix(in srgb, var(--lp-line) 55%, transparent);
|
| 675 |
+
font-size: var(--lp-fs-xs);
|
| 676 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|