Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +23 -0
- VERSION +1 -1
- api/automation_engine.py +0 -0
- api/main.py +7 -0
- api/routes_alerts.py +233 -0
- api/routes_automation.py +40 -5
- api/routes_platform_admin.py +618 -593
- api/routes_shares.py +87 -0
- api/routes_tables.py +161 -4
- platform/core/alerts.py +241 -0
- platform/core/grid_events.py +151 -1
- platform/core/releases.py +94 -0
- platform/core/shares.py +200 -0
- platform/core/store.py +91 -12
- platform/core/store_backend.py +24 -15
- platform/core/store_pg.py +74 -0
- platform/core/table_store.py +15 -0
- platform/core/user_tables.py +259 -34
- platform/harness/runtime.py +20 -4
- platform/modules/customer_data.py +89 -1
- platform/modules/customers.py +36 -3
- requirements.txt +11 -0
- web/src/alerts/AlertsPane.tsx +261 -0
- web/src/alerts/alertsApi.ts +93 -0
- web/src/alerts/alertsModel.ts +218 -0
- web/src/automation/AutomationCreate.tsx +36 -5
- web/src/automation/AutomationDetail.tsx +262 -9
- web/src/automation/AutomationSurface.tsx +13 -4
- web/src/automation/automationApi.ts +80 -7
- web/src/customer-grid/ColumnMenu.tsx +0 -0
- web/src/customer-grid/CustomerGrid.tsx +517 -36
- web/src/customer-grid/ViewSidebar.tsx +449 -141
- web/src/customer-grid/apiBridge.ts +72 -0
- web/src/customer-grid/folders.ts +73 -1
- web/src/customer-grid/liveWorkspace.ts +46 -0
- web/src/customer-grid/overlayPlacement.ts +14 -1
- web/src/customer-grid/types.ts +189 -3
- web/src/customer-grid/undoStack.ts +189 -0
- web/src/customer-grid/useGridColumns.ts +26 -9
- web/src/customer-grid/useGridSelection.ts +4 -0
- web/src/customer-grid/useVisibleRows.ts +6 -1
- web/src/index.css +634 -103
- web/src/settings/AdminPane.tsx +646 -568
- web/src/settings/platformAdminApi.ts +263 -224
- web/src/shell/NavExtras.tsx +21 -1
- web/src/shell/ShareDialog.tsx +328 -0
- web/src/shell/Shell.tsx +267 -44
- web/src/shell/nav.ts +29 -0
- web/src/shell/shareModel.ts +206 -0
RELEASES.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"current": "v3 (6b9fa62)",
|
| 3 |
+
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v3",
|
| 6 |
+
"sha": "6b9fa62",
|
| 7 |
+
"date": "2026-08-05",
|
| 8 |
+
"subject": "release v3"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"version": "v2",
|
| 12 |
+
"sha": "fd05861",
|
| 13 |
+
"date": "2026-08-04",
|
| 14 |
+
"subject": "release v2"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"version": "v1",
|
| 18 |
+
"sha": "5b4e2c4",
|
| 19 |
+
"date": "2026-08-04",
|
| 20 |
+
"subject": "release v1"
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
}
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v3 (6b9fa62)
|
api/automation_engine.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/main.py
CHANGED
|
@@ -55,6 +55,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException # noqa
|
|
| 55 |
|
| 56 |
import aios_session # noqa: E402
|
| 57 |
import routes_admin # noqa: E402
|
|
|
|
| 58 |
import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery)
|
| 59 |
import routes_auth # noqa: E402
|
| 60 |
import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A)
|
|
@@ -67,6 +68,7 @@ import routes_pages # noqa: E402
|
|
| 67 |
import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
|
| 68 |
import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav)
|
| 69 |
import routes_records # noqa: E402
|
|
|
|
| 70 |
import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire)
|
| 71 |
from core import grid_events # noqa: E402
|
| 72 |
from deps import Session, module_gate # noqa: E402
|
|
@@ -193,6 +195,11 @@ app.include_router(routes_statements.router)
|
|
| 193 |
# platform-operator route ever shadowing each other. Every path it declares is gated by
|
| 194 |
# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
|
| 195 |
app.include_router(routes_platform_admin.router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
|
|
|
| 55 |
|
| 56 |
import aios_session # noqa: E402
|
| 57 |
import routes_admin # noqa: E402
|
| 58 |
+
import routes_alerts # noqa: E402 (wave 20 item 25 — the Alerts inbox)
|
| 59 |
import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery)
|
| 60 |
import routes_auth # noqa: E402
|
| 61 |
import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A)
|
|
|
|
| 68 |
import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
|
| 69 |
import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav)
|
| 70 |
import routes_records # noqa: E402
|
| 71 |
+
import routes_shares # noqa: E402 (wave 20 R10 — grants for views, folders and databases)
|
| 72 |
import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire)
|
| 73 |
from core import grid_events # noqa: E402
|
| 74 |
from deps import Session, module_gate # noqa: E402
|
|
|
|
| 195 |
# platform-operator route ever shadowing each other. Every path it declares is gated by
|
| 196 |
# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
|
| 197 |
app.include_router(routes_platform_admin.router)
|
| 198 |
+
# Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are
|
| 199 |
+
# session-gated rather than admin-gated — an alert is a person's own subscription, and sharing is
|
| 200 |
+
# something every user does with their own views/folders/databases.
|
| 201 |
+
app.include_router(routes_alerts.router)
|
| 202 |
+
app.include_router(routes_shares.router)
|
| 203 |
|
| 204 |
|
| 205 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
api/routes_alerts.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_alerts.py — the Alerts module (wave 20, owner item 25, contract C-ALERT).
|
| 2 |
+
|
| 3 |
+
GET /api/v1/alerts -> {alerts:[...]}
|
| 4 |
+
POST /api/v1/alerts <- {viewId, topic, label?}
|
| 5 |
+
DELETE /api/v1/alerts/{alert_id}
|
| 6 |
+
POST /api/v1/alerts/{alert_id}/run -> evaluate now (the pane's manual refresh)
|
| 7 |
+
GET /api/v1/notifications -> {unread, items:[...]}
|
| 8 |
+
POST /api/v1/notifications/read <- {ids:[...]|null, read?:bool}
|
| 9 |
+
|
| 10 |
+
The semantics — an alert is a view plus a remembered matched set, a notification is a NEW
|
| 11 |
+
ENTRANT, and the first evaluation seeds silently — live in `core.alerts` with the reasoning.
|
| 12 |
+
This file owns the two things a route must: WHO may do it, and HOW the view gets evaluated.
|
| 13 |
+
|
| 14 |
+
⭐ **THE EVALUATION RUNS AS THE ALERT'S OWNER, NOT AS THE CALLER.** `_run_alert` builds the pool
|
| 15 |
+
for `rec['owner']`, never for whoever tripped the write hook. Any other choice leaks: a
|
| 16 |
+
full-access admin editing a cell would otherwise evaluate a BU-scoped user's alert over the whole
|
| 17 |
+
book, and the notification would name customers that user may not see — a permission leak wearing
|
| 18 |
+
a notification's clothes. The owner's own scope is the only correct basis for their alert.
|
| 19 |
+
|
| 20 |
+
⚠ **AN ALERT IS NOT A SECOND READ PATH.** It resolves rows through the same
|
| 21 |
+
`routes_customers.grid_assembly` / `routes_tables.ut_assembly` the grid uses, so a row that an
|
| 22 |
+
alert can see is by construction a row its owner could open. Re-implementing the filter here
|
| 23 |
+
would be a second definition of "matches", and those two would drift.
|
| 24 |
+
"""
|
| 25 |
+
from fastapi import APIRouter, Body, Depends
|
| 26 |
+
|
| 27 |
+
import core.alerts as alerts
|
| 28 |
+
from deps import Session, err, require_session
|
| 29 |
+
|
| 30 |
+
router = APIRouter(prefix="/api/v1")
|
| 31 |
+
|
| 32 |
+
#: The alert-bearing surfaces. `ut_` tables are admitted by prefix, like everywhere else.
|
| 33 |
+
_TOPICS = ("customer", "product")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _topic_or_400(raw):
|
| 37 |
+
topic = str(raw or "").strip().lower()
|
| 38 |
+
if topic.startswith("ut_") or topic in _TOPICS:
|
| 39 |
+
return topic
|
| 40 |
+
raise err(400, "bad_topic", f"topic must be one of {', '.join(_TOPICS)} or a ut_ table")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _owner_session(session: Session, owner: str):
|
| 44 |
+
"""A `Session` for the alert's OWNER (see the module note on why the owner, not the caller).
|
| 45 |
+
|
| 46 |
+
⚠ `Session` exposes `uname`/`admin` as PROPERTIES derived from `user`, not as fields — so an
|
| 47 |
+
owner session is built by swapping the `user` RECORD and letting both derive themselves. An
|
| 48 |
+
earlier version passed `uname=`/`admin=` to the constructor, which would have raised on the
|
| 49 |
+
first write hook of the wave; the properties are the single definition of who a session is,
|
| 50 |
+
and going around them is how a session with an admin flag and a non-admin record exists.
|
| 51 |
+
|
| 52 |
+
Returns None when the owner is gone or deactivated — their alerts then stop evaluating rather
|
| 53 |
+
than evaluating as somebody else, which is the fail-closed direction.
|
| 54 |
+
"""
|
| 55 |
+
import core.users as users
|
| 56 |
+
|
| 57 |
+
if str(owner) == str(session.uname):
|
| 58 |
+
return session
|
| 59 |
+
rec = (users.registry() or {}).get(str(owner))
|
| 60 |
+
if not isinstance(rec, dict) or not rec.get("active", True):
|
| 61 |
+
return None
|
| 62 |
+
# `_public` is THE definition of what a session may know about its own account (never a hash
|
| 63 |
+
# or a salt) — the same one `routes_auth` uses. Building the dict by hand here would be a
|
| 64 |
+
# second definition, and the one that leaks is always the copy.
|
| 65 |
+
return Session(tenant=session.tenant, user=users._public(str(owner), rec),
|
| 66 |
+
claims=session.claims, runtime=session.runtime)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _evaluate(session: Session, rec: dict):
|
| 70 |
+
"""Resolve `rec`'s view over its topic AS THE ALERT'S OWNER, then fold the result in."""
|
| 71 |
+
import aios_grid
|
| 72 |
+
from harness import filter_eval
|
| 73 |
+
|
| 74 |
+
owner_sess = _owner_session(session, rec.get("owner"))
|
| 75 |
+
if owner_sess is None:
|
| 76 |
+
return {"skipped": "owner_unavailable"}
|
| 77 |
+
topic = str(rec.get("topic") or "")
|
| 78 |
+
try:
|
| 79 |
+
if topic.startswith("ut_"):
|
| 80 |
+
from routes_tables import ut_assembly
|
| 81 |
+
g = ut_assembly(owner_sess, topic,
|
| 82 |
+
storage_key=f"{owner_sess.tenant}:{topic}:{owner_sess.uname}")
|
| 83 |
+
else:
|
| 84 |
+
from routes_customers import grid_assembly
|
| 85 |
+
g = grid_assembly(owner_sess, scope=topic, consume_corrections=False)
|
| 86 |
+
except Exception as e: # noqa: BLE001
|
| 87 |
+
return {"skipped": "unavailable", "detail": type(e).__name__}
|
| 88 |
+
|
| 89 |
+
view = (g.get("views") or {}).get(str(rec.get("viewId")))
|
| 90 |
+
if not isinstance(view, dict):
|
| 91 |
+
# Deleted, or un-shared out from under the alert. Say so on the RECORD rather than
|
| 92 |
+
# deleting the alert: an alert that silently vanishes is indistinguishable from one that
|
| 93 |
+
# never fires, and the user cannot debug what is not there.
|
| 94 |
+
return {"skipped": "view_missing"}
|
| 95 |
+
|
| 96 |
+
# The SAME row build the grid and `/customers` use — `rows_from_pool` is what puts derived
|
| 97 |
+
# and overlay values on a row. Evaluating a filter against raw pool dicts would silently
|
| 98 |
+
# never match any condition on a user-created or measure column.
|
| 99 |
+
rows = aios_grid.rows_from_pool(g["rows_src"], g["fields"], g["ws"].get("overlays"),
|
| 100 |
+
derived=g.get("derived"))
|
| 101 |
+
config = view.get("config") or view
|
| 102 |
+
ctx = filter_eval.EvalCtx(
|
| 103 |
+
cohort_sets={str(k): {str(p) for p in (v.get("memberPids") or ())}
|
| 104 |
+
for k, v in (g.get("lists") or {}).items() if isinstance(v, dict)},
|
| 105 |
+
measure_sets=g.get("measure_sets") or {},
|
| 106 |
+
today=g.get("today"))
|
| 107 |
+
pids = filter_eval.visible_pids(config.get("filters") or [], rows, g["fields"], ctx,
|
| 108 |
+
member_pids=config.get("memberPids"))
|
| 109 |
+
labels = {str(r.get("pid")): str(r.get("name") or r.get("pid")) for r in rows}
|
| 110 |
+
return alerts.evaluate(rec.get("id"), [str(p) for p in pids],
|
| 111 |
+
labels=labels, partial=False, st=session.runtime)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/alerts")
|
| 115 |
+
def list_alerts(session: Session = Depends(require_session)):
|
| 116 |
+
return {"alerts": alerts.list_alerts(user=session.uname, is_admin=session.admin,
|
| 117 |
+
st=session.runtime)}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.post("/alerts")
|
| 121 |
+
def create_alert(body: dict = Body(default=None), session: Session = Depends(require_session)):
|
| 122 |
+
body = body or {}
|
| 123 |
+
view_id = str(body.get("viewId") or "").strip()
|
| 124 |
+
if not view_id:
|
| 125 |
+
raise err(400, "bad_view", "an alert needs the id of the view it watches")
|
| 126 |
+
topic = _topic_or_400(body.get("topic"))
|
| 127 |
+
_require_filtered_view(session, topic, view_id)
|
| 128 |
+
import uuid
|
| 129 |
+
aid = f"al_{uuid.uuid4().hex[:12]}"
|
| 130 |
+
rec = alerts.create(aid, view_id=view_id, topic=topic, owner=session.uname,
|
| 131 |
+
label=body.get("label") or "", st=session.runtime)
|
| 132 |
+
# SEED IMMEDIATELY, so the alert starts from "everything currently matching is old news".
|
| 133 |
+
# Deferring this to the first write hook would mean the next edit announces the whole view.
|
| 134 |
+
outcome = _evaluate(session, rec)
|
| 135 |
+
return {"alert": {**rec, "seeded": True}, "first": outcome}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _require_filtered_view(session: Session, topic: str, view_id: str):
|
| 139 |
+
"""400 unless `view_id` exists on `topic` AND actually narrows something.
|
| 140 |
+
|
| 141 |
+
⛔ AN ALERT ON AN UNFILTERED VIEW IS SILENTLY INCAPABLE OF ALERTING, which is worse than one
|
| 142 |
+
that is refused. `filter_eval` treats an inactive tree as "no narrowing, every row shows"
|
| 143 |
+
(`visible_pids`'s own rule), so such an alert seeds with the entire table and can never see an
|
| 144 |
+
entrant again — there is nothing left to enter. The owner's words are *"when a Record gets
|
| 145 |
+
into that Filter's criteria"*: no criteria, no alert, and said at creation rather than
|
| 146 |
+
discovered by never being notified.
|
| 147 |
+
|
| 148 |
+
`is_rule_active` is the SAME activeness predicate the engine and the column tints use — a
|
| 149 |
+
half-typed rule is not a filter, and this must agree with what actually narrows or it would
|
| 150 |
+
accept a view whose one rule the engine then ignores.
|
| 151 |
+
"""
|
| 152 |
+
from harness import filter_eval
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
if topic.startswith("ut_"):
|
| 156 |
+
from routes_tables import ut_assembly
|
| 157 |
+
g = ut_assembly(session, topic,
|
| 158 |
+
storage_key=f"{session.tenant}:{topic}:{session.uname}")
|
| 159 |
+
else:
|
| 160 |
+
from routes_customers import grid_assembly
|
| 161 |
+
g = grid_assembly(session, scope=topic, consume_corrections=False)
|
| 162 |
+
except Exception: # noqa: BLE001
|
| 163 |
+
raise err(503, "unavailable", "the table is unavailable — try again in a moment")
|
| 164 |
+
view = (g.get("views") or {}).get(str(view_id))
|
| 165 |
+
if not isinstance(view, dict):
|
| 166 |
+
raise err(404, "no_view", "that view does not exist on this table")
|
| 167 |
+
nodes, _conj = filter_eval.tree_parts((view.get("config") or view).get("filters") or [])
|
| 168 |
+
|
| 169 |
+
def _any_active(ns):
|
| 170 |
+
for n in ns or ():
|
| 171 |
+
if isinstance(n, dict) and isinstance(n.get("children"), list):
|
| 172 |
+
if _any_active(n["children"]):
|
| 173 |
+
return True
|
| 174 |
+
elif filter_eval.is_rule_active(n):
|
| 175 |
+
return True
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
if not _any_active(nodes):
|
| 179 |
+
raise err(400, "no_filter",
|
| 180 |
+
"this view has no active filter, so no record can ever ENTER it — add a "
|
| 181 |
+
"condition to the view first, then create the alert")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.delete("/alerts/{alert_id}")
|
| 185 |
+
def delete_alert(alert_id: str, session: Session = Depends(require_session)):
|
| 186 |
+
rec = next((r for r in alerts.list_alerts(st=session.runtime)
|
| 187 |
+
if str(r.get("id")) == str(alert_id)), None)
|
| 188 |
+
if rec is None:
|
| 189 |
+
raise err(404, "no_alert", "that alert does not exist")
|
| 190 |
+
if str(rec.get("owner")) != str(session.uname) and not session.admin:
|
| 191 |
+
raise err(403, "not_yours", "only the alert's owner (or an administrator) can delete it")
|
| 192 |
+
alerts.delete(alert_id, st=session.runtime)
|
| 193 |
+
return {"ok": True}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@router.post("/alerts/{alert_id}/run")
|
| 197 |
+
def run_alert(alert_id: str, session: Session = Depends(require_session)):
|
| 198 |
+
rec = next((r for r in alerts.list_alerts(user=session.uname, is_admin=session.admin,
|
| 199 |
+
st=session.runtime)
|
| 200 |
+
if str(r.get("id")) == str(alert_id)), None)
|
| 201 |
+
if rec is None:
|
| 202 |
+
raise err(404, "no_alert", "that alert does not exist")
|
| 203 |
+
return _evaluate(session, rec)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@router.get("/notifications")
|
| 207 |
+
def notifications(session: Session = Depends(require_session)):
|
| 208 |
+
return alerts.inbox(session.uname, st=session.runtime)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@router.post("/notifications/read")
|
| 212 |
+
def read_notifications(body: dict = Body(default=None),
|
| 213 |
+
session: Session = Depends(require_session)):
|
| 214 |
+
body = body or {}
|
| 215 |
+
ids = body.get("ids")
|
| 216 |
+
if ids is not None and not isinstance(ids, list):
|
| 217 |
+
raise err(400, "bad_ids", "ids must be a list, or null to mark every notification")
|
| 218 |
+
return alerts.mark_read(session.uname, ids, read=bool(body.get("read", True)),
|
| 219 |
+
st=session.runtime)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def after_write(session: Session, topic_key: str):
|
| 223 |
+
"""THE WRITE HOOK — call after a write that could change what a view matches.
|
| 224 |
+
|
| 225 |
+
Exported as a plain function (not a route) so `core.grid_events`' callers and S2's automation
|
| 226 |
+
upserts reach it the same way. It never raises: an alert evaluation failing must not fail the
|
| 227 |
+
edit that triggered it.
|
| 228 |
+
"""
|
| 229 |
+
try:
|
| 230 |
+
return alerts.after_write(topic_key, st=session.runtime,
|
| 231 |
+
runner=lambda rec: _evaluate(session, rec))
|
| 232 |
+
except Exception: # noqa: BLE001
|
| 233 |
+
return {"evaluated": 0}
|
api/routes_automation.py
CHANGED
|
@@ -64,16 +64,43 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 64 |
sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
|
| 65 |
return {"automations": items,
|
| 66 |
"kinds": [{"key": "scrape_db", "label": "Web page to database"},
|
| 67 |
-
{"key": "field_instagram", "label": "Instagram profile column"}
|
|
|
|
| 68 |
"cronPresets": engine.CRON_PRESETS,
|
| 69 |
# ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not
|
| 70 |
# configured" honestly instead of offering a tier that will silently refuse — and
|
| 71 |
# that needs exactly one bit. Shipping the key itself to a browser would put a
|
| 72 |
# billable secret in every user's devtools.
|
| 73 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
"storeAvailable": bool(session.runtime.available())}
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
@router.get("/automations/tables")
|
| 78 |
def automation_tables(session: Session = Depends(_GATE)):
|
| 79 |
"""The blank databases an automation can target, with their fields.
|
|
@@ -82,13 +109,21 @@ def automation_tables(session: Session = Depends(_GATE)):
|
|
| 82 |
two can never collide. It exists because the automation editor needs the table+field list to
|
| 83 |
build a config at all, and D must not block on A's route landing. When C3-UT is live this
|
| 84 |
keeps working (same bucket) — it is a duplicate reader, never a second writer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
"""
|
|
|
|
|
|
|
| 86 |
out = []
|
| 87 |
for key, t in sorted(engine.ut_all(session.runtime).items(),
|
| 88 |
key=lambda kv: (kv[1].get("label") or "").lower()):
|
| 89 |
-
if not
|
| 90 |
-
|
| 91 |
-
continue # user_tables.may_open's rule, mirrored fail-closed
|
| 92 |
out.append({"key": key, "label": t.get("label") or key,
|
| 93 |
"source": t.get("source") or "Blank",
|
| 94 |
"rowCount": len(t.get("rows") or {}),
|
|
|
|
| 64 |
sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower())]
|
| 65 |
return {"automations": items,
|
| 66 |
"kinds": [{"key": "scrape_db", "label": "Web page to database"},
|
| 67 |
+
{"key": "field_instagram", "label": "Instagram profile column"},
|
| 68 |
+
{"key": "discover_instagram", "label": "Find Instagram profiles"}],
|
| 69 |
"cronPresets": engine.CRON_PRESETS,
|
| 70 |
# ⚠ A BOOLEAN, NEVER THE KEY. The surface needs to say "the paid rung is not
|
| 71 |
# configured" honestly instead of offering a tier that will silently refuse — and
|
| 72 |
# that needs exactly one bit. Shipping the key itself to a browser would put a
|
| 73 |
# billable secret in every user's devtools.
|
| 74 |
+
"paidReady": engine.bd_ready(),
|
| 75 |
+
# THE SOURCE REGISTRY (D-9's seam), on the wire for the same reason `cronPresets` is:
|
| 76 |
+
# the module that RUNS a source is the only thing entitled to say what it can do, and
|
| 77 |
+
# a client copy of "Instagram can discover, TikTok cannot" goes stale in silence.
|
| 78 |
+
"sources": engine.source_status(),
|
| 79 |
+
# The discovery vocabulary, likewise server-owned: every name here is MEASURED-
|
| 80 |
+
# accepted by the vendor's own validator, so a client that invented one would build a
|
| 81 |
+
# query the API rejects. `lead` is the subset seen carrying VALUES on real rows.
|
| 82 |
+
"discover": {"fields": list(engine.BD_FILTER_FIELDS),
|
| 83 |
+
"lead": list(engine.BD_FILTER_LEAD),
|
| 84 |
+
"operators": list(engine.BD_FILTER_OPS),
|
| 85 |
+
"nullaryOperators": list(engine.BD_NULLARY_OPS),
|
| 86 |
+
"maxRecords": engine.BD_MAX_RECORDS,
|
| 87 |
+
"table": engine.DISCOVER_TABLE},
|
| 88 |
"storeAvailable": bool(session.runtime.available())}
|
| 89 |
|
| 90 |
|
| 91 |
+
@router.post("/automations/discover/estimate")
|
| 92 |
+
def discover_estimate(body: dict = Body(default=None), session: Session = Depends(_GATE)):
|
| 93 |
+
"""What would this search cost? Shown BEFORE the run, never after.
|
| 94 |
+
|
| 95 |
+
⚠ THE ANSWER IS AN ESTIMATE AND SAYS SO IN ITS OWN PAYLOAD (`basis: "SPEC"`). Bright Data
|
| 96 |
+
never returns a price before a run — the funds gate fires first and `price: 0` means "not
|
| 97 |
+
priced", not "free" — and this account's token cannot read a balance (`/customer/balance`
|
| 98 |
+
answers 403). A number presented as billed would be the invented measurement this whole
|
| 99 |
+
module refuses to make.
|
| 100 |
+
"""
|
| 101 |
+
return engine.discover_estimate((body or {}).get("recordsLimit"))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
@router.get("/automations/tables")
|
| 105 |
def automation_tables(session: Session = Depends(_GATE)):
|
| 106 |
"""The blank databases an automation can target, with their fields.
|
|
|
|
| 109 |
two can never collide. It exists because the automation editor needs the table+field list to
|
| 110 |
build a config at all, and D must not block on A's route landing. When C3-UT is live this
|
| 111 |
keeps working (same bucket) — it is a duplicate reader, never a second writer.
|
| 112 |
+
|
| 113 |
+
⛔ WAVE 20, item 3 — IT NO LONGER MIRRORS THE WALL, IT CALLS IT. This route re-implemented
|
| 114 |
+
`may_open` and got it WIDER: it also admitted `createdBy in ('automation', 'scheduler')`, so
|
| 115 |
+
a non-admin saw automation-created databases here and was refused the moment they opened,
|
| 116 |
+
edited or deleted one. A duplicate READER is fine; a duplicate WALL is not, because the two
|
| 117 |
+
only disagree in front of a user. One resolver now, and the engine stamps a human owner so
|
| 118 |
+
the merge takes nothing legitimate away (`ut_ensure`, `MACHINE_OWNERS`).
|
| 119 |
"""
|
| 120 |
+
import core.user_tables as user_tables
|
| 121 |
+
|
| 122 |
out = []
|
| 123 |
for key, t in sorted(engine.ut_all(session.runtime).items(),
|
| 124 |
key=lambda kv: (kv[1].get("label") or "").lower()):
|
| 125 |
+
if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
|
| 126 |
+
continue
|
|
|
|
| 127 |
out.append({"key": key, "label": t.get("label") or key,
|
| 128 |
"source": t.get("source") or "Blank",
|
| 129 |
"rowCount": len(t.get("rows") or {}),
|
api/routes_platform_admin.py
CHANGED
|
@@ -1,593 +1,618 @@
|
|
| 1 |
-
"""routes_platform_admin.py — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 / R3+R4).
|
| 2 |
-
|
| 3 |
-
Every other admin surface in this product answers questions about ONE tenant. This one answers
|
| 4 |
-
questions about the PLATFORM: who our customers are, what they are running, whether their data
|
| 5 |
-
sources are alive, and what the automation fleet costs. It is the first cross-tenant reader that
|
| 6 |
-
has ever existed here, which is why it is also the most carefully walled.
|
| 7 |
-
|
| 8 |
-
⛔ THE WALL. `padmin_gate` is `core.platform_admin.is_platform_admin` — a DOUBLE lock (the record
|
| 9 |
-
flag AND the `loopable` tenant), fail-closed, applied to every single route in this file through
|
| 10 |
-
one dependency. A tenant admin is NOT admitted: `role: 'admin'` is Royal's or Nurilab's authority
|
| 11 |
-
over their own workspace and it must never widen into a view of each other. `verify_api.py`'s
|
| 12 |
-
W19-ADMIN section proves that by ENUMERATING this router and having a tenant admin try every path
|
| 13 |
-
it declares, so a route added later cannot quietly ship without the wall.
|
| 14 |
-
|
| 15 |
-
⛔ CROSS-TENANT READS GO THROUGH EACH TENANT'S OWN RUNTIME. `runtime.get_runtime(slug)` per tenant,
|
| 16 |
-
then `rt.get(...)` — never `core.store.get("<raw key>")`. The runtime is what applies the store
|
| 17 |
-
NAMESPACE (`t/<slug>/…`) or binds the tenant's OWN dataset repo (R2), so reading raw keys would
|
| 18 |
-
silently return tenant #0's data labelled as somebody else's — a wrong answer that looks right,
|
| 19 |
-
which is the worst failure this plane could have.
|
| 20 |
-
|
| 21 |
-
HONEST DEGRADATION IS THE WHOLE DESIGN, NOT AN ERROR PATH. This plane reads six subsystems across
|
| 22 |
-
N tenants; on any given day one of them can be unreachable (a suspended tenant record, a locked
|
| 23 |
-
keychain, an HF repo hiccup, no AWS credentials in this container). A 500 would take the entire
|
| 24 |
-
dashboard down because one cell could not be filled. So every collector catches, and every row can
|
| 25 |
-
carry an `error` string that the pane RENDERS — "unknown" is a real answer and it is never
|
| 26 |
-
rendered as a zero. ([[gate-can-report-green-on-nothing]]: a fabricated 0 and a true 0 must not
|
| 27 |
-
look alike.)
|
| 28 |
-
|
| 29 |
-
EVERY COUNT DRILLS TO ROWS. `/overview`'s per-tenant counts are computed by the SAME collector
|
| 30 |
-
functions the `/users`, `/databases`, `/connectors` and `/automations` routes serve rows from, so
|
| 31 |
-
a number and its drill-down cannot disagree — they are one computation, projected twice
|
| 32 |
-
([[no-unverifiable-aggregates]]).
|
| 33 |
-
"""
|
| 34 |
-
import
|
| 35 |
-
import
|
| 36 |
-
import
|
| 37 |
-
import
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
import core.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
the
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
which
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
that
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
#
|
| 132 |
-
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
-
|
| 138 |
-
#
|
| 139 |
-
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
`
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
""
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
"
|
| 176 |
-
"
|
| 177 |
-
"
|
| 178 |
-
"
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
"
|
| 210 |
-
"
|
| 211 |
-
"
|
| 212 |
-
"
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
would
|
| 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 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 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 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
"
|
| 355 |
-
"
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
"
|
| 359 |
-
"
|
| 360 |
-
"
|
| 361 |
-
"
|
| 362 |
-
|
| 363 |
-
f"
|
| 364 |
-
f"
|
| 365 |
-
f"
|
| 366 |
-
f"{round(
|
| 367 |
-
f"
|
| 368 |
-
f"
|
| 369 |
-
f"
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
""
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
"
|
| 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 |
-
row
|
| 447 |
-
row["
|
| 448 |
-
|
| 449 |
-
if
|
| 450 |
-
|
| 451 |
-
#
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 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 |
-
"
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
#
|
| 491 |
-
#
|
| 492 |
-
#
|
| 493 |
-
#
|
| 494 |
-
#
|
| 495 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_platform_admin.py — THE LOOPABLE ADMIN PLANE (wave 19, owner item 13 / R3+R4).
|
| 2 |
+
|
| 3 |
+
Every other admin surface in this product answers questions about ONE tenant. This one answers
|
| 4 |
+
questions about the PLATFORM: who our customers are, what they are running, whether their data
|
| 5 |
+
sources are alive, and what the automation fleet costs. It is the first cross-tenant reader that
|
| 6 |
+
has ever existed here, which is why it is also the most carefully walled.
|
| 7 |
+
|
| 8 |
+
⛔ THE WALL. `padmin_gate` is `core.platform_admin.is_platform_admin` — a DOUBLE lock (the record
|
| 9 |
+
flag AND the `loopable` tenant), fail-closed, applied to every single route in this file through
|
| 10 |
+
one dependency. A tenant admin is NOT admitted: `role: 'admin'` is Royal's or Nurilab's authority
|
| 11 |
+
over their own workspace and it must never widen into a view of each other. `verify_api.py`'s
|
| 12 |
+
W19-ADMIN section proves that by ENUMERATING this router and having a tenant admin try every path
|
| 13 |
+
it declares, so a route added later cannot quietly ship without the wall.
|
| 14 |
+
|
| 15 |
+
⛔ CROSS-TENANT READS GO THROUGH EACH TENANT'S OWN RUNTIME. `runtime.get_runtime(slug)` per tenant,
|
| 16 |
+
then `rt.get(...)` — never `core.store.get("<raw key>")`. The runtime is what applies the store
|
| 17 |
+
NAMESPACE (`t/<slug>/…`) or binds the tenant's OWN dataset repo (R2), so reading raw keys would
|
| 18 |
+
silently return tenant #0's data labelled as somebody else's — a wrong answer that looks right,
|
| 19 |
+
which is the worst failure this plane could have.
|
| 20 |
+
|
| 21 |
+
HONEST DEGRADATION IS THE WHOLE DESIGN, NOT AN ERROR PATH. This plane reads six subsystems across
|
| 22 |
+
N tenants; on any given day one of them can be unreachable (a suspended tenant record, a locked
|
| 23 |
+
keychain, an HF repo hiccup, no AWS credentials in this container). A 500 would take the entire
|
| 24 |
+
dashboard down because one cell could not be filled. So every collector catches, and every row can
|
| 25 |
+
carry an `error` string that the pane RENDERS — "unknown" is a real answer and it is never
|
| 26 |
+
rendered as a zero. ([[gate-can-report-green-on-nothing]]: a fabricated 0 and a true 0 must not
|
| 27 |
+
look alike.)
|
| 28 |
+
|
| 29 |
+
EVERY COUNT DRILLS TO ROWS. `/overview`'s per-tenant counts are computed by the SAME collector
|
| 30 |
+
functions the `/users`, `/databases`, `/connectors` and `/automations` routes serve rows from, so
|
| 31 |
+
a number and its drill-down cannot disagree — they are one computation, projected twice
|
| 32 |
+
([[no-unverifiable-aggregates]]).
|
| 33 |
+
"""
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import subprocess
|
| 37 |
+
import sys
|
| 38 |
+
import time
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
from fastapi import APIRouter, Depends
|
| 42 |
+
|
| 43 |
+
import core.platform_admin as platform_admin
|
| 44 |
+
import core.store as store
|
| 45 |
+
from deps import Session, err, require_session, users
|
| 46 |
+
|
| 47 |
+
router = APIRouter(prefix="/api/v1/platform-admin")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _now_iso():
|
| 51 |
+
"""UTC, offset-bearing — the one stamp format anything client-side may subtract from."""
|
| 52 |
+
import datetime as _dt
|
| 53 |
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def padmin_gate(session: Session = Depends(require_session)) -> Session:
|
| 57 |
+
"""401 without a session, 403 unless this account is a Loopable platform operator.
|
| 58 |
+
|
| 59 |
+
ONE dependency for the whole router. A per-route check is a per-route chance to forget, and
|
| 60 |
+
the thing being forgotten here would be every customer's data at once.
|
| 61 |
+
|
| 62 |
+
The message deliberately does not confirm that a platform plane exists for somebody else —
|
| 63 |
+
a tenant admin who pokes at this URL learns only that their account cannot open it.
|
| 64 |
+
"""
|
| 65 |
+
if not platform_admin.is_platform_admin(session.user):
|
| 66 |
+
raise err(403, "forbidden", "your account does not have access to this surface")
|
| 67 |
+
return session
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ══════════════════════════════════════════════════════════════════════════ tenants
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _tenant_bucket():
|
| 74 |
+
"""The control-plane `tenants` records, or {} — a platform fact, in the DEFAULT store."""
|
| 75 |
+
from harness import runtime
|
| 76 |
+
try:
|
| 77 |
+
recs = store.get(runtime.TENANTS_KEY) or {}
|
| 78 |
+
return {str(k).strip().lower(): v for k, v in recs.items() if isinstance(v, dict)}
|
| 79 |
+
except Exception:
|
| 80 |
+
return {}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _slugs():
|
| 84 |
+
"""Every tenant this deployment knows: compiled builders + control-plane records.
|
| 85 |
+
|
| 86 |
+
`runtime.known_tenants()` is the union and it is the same list login resolves against, so
|
| 87 |
+
this plane cannot show a customer the door does not recognise (or miss one it does).
|
| 88 |
+
"""
|
| 89 |
+
from harness import runtime
|
| 90 |
+
try:
|
| 91 |
+
return list(runtime.known_tenants())
|
| 92 |
+
except Exception:
|
| 93 |
+
return sorted(_tenant_bucket())
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _runtime_for(slug):
|
| 97 |
+
"""(runtime, error). A tenant whose runtime will not build is a ROW WITH A NOTE, never a 500.
|
| 98 |
+
|
| 99 |
+
Two real cases produce one: a SUSPENDED record (`get_runtime` raises KeyError by design —
|
| 100 |
+
"which tenants exist" is not a question the login path answers), and tenant #0's builder,
|
| 101 |
+
which makes a LIVE Odoo call (`harness.tenants.royal_imports` → `excluded_customer_ids`) and
|
| 102 |
+
therefore fails on a box that cannot reach the ERP. Neither may take the dashboard down.
|
| 103 |
+
"""
|
| 104 |
+
from harness import runtime
|
| 105 |
+
try:
|
| 106 |
+
return runtime.get_runtime(slug), ""
|
| 107 |
+
except KeyError:
|
| 108 |
+
return None, "not resolvable (suspended, or no builder and no active record)"
|
| 109 |
+
except Exception as e: # noqa: BLE001
|
| 110 |
+
return None, f"runtime unavailable ({type(e).__name__})"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _scan(want=""):
|
| 114 |
+
"""[(row, runtime)] — every tenant (or one), with its runtime RESOLVED EXACTLY ONCE.
|
| 115 |
+
|
| 116 |
+
The one-resolution rule is not tidiness. `get_runtime` is LRU-cached on success, but a tenant
|
| 117 |
+
that FAILS to build is not cached, and tenant #0's builder makes a live Odoo call — so a route
|
| 118 |
+
that asked twice would pay the timeout twice, and `/overview` (which asks about six
|
| 119 |
+
subsystems) would pay it six times. Resolve once, pass the handle down.
|
| 120 |
+
"""
|
| 121 |
+
bucket = _tenant_bucket()
|
| 122 |
+
want = str(want or "").strip().lower()
|
| 123 |
+
out = []
|
| 124 |
+
for slug in _slugs():
|
| 125 |
+
if want and slug != want:
|
| 126 |
+
continue
|
| 127 |
+
rec = bucket.get(slug) or {}
|
| 128 |
+
rt, error = _runtime_for(slug)
|
| 129 |
+
out.append(({
|
| 130 |
+
"slug": slug,
|
| 131 |
+
# The record's name, else the built runtime's, else the slug. Tenant #0 is compiled
|
| 132 |
+
# and has no record, so without the runtime fallback it would render as "royal-imports".
|
| 133 |
+
"name": str(rec.get("name") or (getattr(rt, "name", "") if rt else "") or slug),
|
| 134 |
+
"source": "record" if rec else "compiled",
|
| 135 |
+
"status": str(rec.get("status") or ("active" if rt else "unknown")),
|
| 136 |
+
"domains": list(rec.get("domains") or []),
|
| 137 |
+
"modules": rec.get("modules", "all" if not rec else []),
|
| 138 |
+
# R2: the isolation shape. "own repo" and "shared repo + prefix" are genuinely
|
| 139 |
+
# different blast radii and an operator should be able to see which is which.
|
| 140 |
+
"storeRepo": rec.get("store_repo") or ("shared (tenant #0 repo)" if not rec else ""),
|
| 141 |
+
"storePrefix": getattr(rt, "store_namespace", "") if rt else "",
|
| 142 |
+
"error": error,
|
| 143 |
+
}, rt))
|
| 144 |
+
return out
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ══════════════════════════════════════════════════════════════════════════ users
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _user_rows(tenant=None):
|
| 151 |
+
"""Accounts across every tenant, from the GLOBAL registry (`users.json` is control-plane).
|
| 152 |
+
|
| 153 |
+
⛔ NEVER `salt` OR `hash`. This projection is the only one these routes use, mirroring
|
| 154 |
+
`routes_admin._view`'s discipline: one function that can leak, and it does not.
|
| 155 |
+
|
| 156 |
+
R4's two new fields ride here — `lastLogin` / `lastActive`, absent on every pre-wave record,
|
| 157 |
+
rendered as "never" rather than as a fabricated date.
|
| 158 |
+
"""
|
| 159 |
+
try:
|
| 160 |
+
reg = users.registry() or {}
|
| 161 |
+
except Exception:
|
| 162 |
+
return []
|
| 163 |
+
want = str(tenant or "").strip().lower()
|
| 164 |
+
rows = []
|
| 165 |
+
for uname, rec in sorted(reg.items()):
|
| 166 |
+
if not isinstance(rec, dict):
|
| 167 |
+
continue
|
| 168 |
+
slug = str(rec.get("tenant") or "royal-imports").strip().lower()
|
| 169 |
+
if want and slug != want:
|
| 170 |
+
continue
|
| 171 |
+
rows.append({
|
| 172 |
+
"username": uname,
|
| 173 |
+
"name": rec.get("name") or uname,
|
| 174 |
+
"email": rec.get("email") or "",
|
| 175 |
+
"tenant": slug,
|
| 176 |
+
"role": rec.get("role", "user"),
|
| 177 |
+
"active": bool(rec.get("active", True)),
|
| 178 |
+
"lastLogin": rec.get("last_login") or "",
|
| 179 |
+
"lastActive": rec.get("last_active") or "",
|
| 180 |
+
"platformAdmin": rec.get("platform_admin") is True,
|
| 181 |
+
})
|
| 182 |
+
return rows
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ══════════════════════════════════════════════════════════════════════════ databases
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _database_rows(slug, rt):
|
| 189 |
+
"""A tenant's user-created databases (`ut_*`) with their row counts.
|
| 190 |
+
|
| 191 |
+
Via `core.user_tables.all_tables(st=rt)` — the TenantRuntime, never the module-global, which
|
| 192 |
+
the engine's own header (`automation_engine.py:49-52`) flags as a cross-tenant defect for
|
| 193 |
+
every R2 tenant. That booked defect is exactly the mistake a cross-tenant reader would make
|
| 194 |
+
most easily, so it is stated at the call site too.
|
| 195 |
+
"""
|
| 196 |
+
try:
|
| 197 |
+
import core.user_tables as ut
|
| 198 |
+
tables = ut.all_tables(st=rt) or {}
|
| 199 |
+
except Exception as e: # noqa: BLE001
|
| 200 |
+
return [], f"databases unreadable ({type(e).__name__})"
|
| 201 |
+
rows = []
|
| 202 |
+
for key, t in sorted(tables.items(), key=lambda kv: (kv[1].get("label") or "").lower()):
|
| 203 |
+
if not isinstance(t, dict):
|
| 204 |
+
continue
|
| 205 |
+
rows.append({
|
| 206 |
+
"tenant": slug,
|
| 207 |
+
"key": key,
|
| 208 |
+
"label": t.get("label") or key,
|
| 209 |
+
"source": t.get("source") or "Blank",
|
| 210 |
+
"createdBy": t.get("createdBy") or "",
|
| 211 |
+
"created": t.get("created") or "",
|
| 212 |
+
"fields": len(t.get("fields") or []),
|
| 213 |
+
"rowCount": len(t.get("rows") or {}),
|
| 214 |
+
})
|
| 215 |
+
return rows, ""
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ════════════════════���═════════════════════════════════════════════════════ connectors
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _connector_rows(slug, rt):
|
| 222 |
+
"""A tenant's data sources and which one is actually RESOLVED (i.e. would serve a query).
|
| 223 |
+
|
| 224 |
+
`_resolved_odoo_key` is imported from `routes_keychain` rather than re-derived. It is the
|
| 225 |
+
route-level mirror of `TenantRuntime.odoo_source()`, it takes a runtime (not a session), and
|
| 226 |
+
a second copy of that resolution here would be a copy that drifts — at which point this plane
|
| 227 |
+
would confidently name the wrong live connector. Reused, not restated.
|
| 228 |
+
"""
|
| 229 |
+
try:
|
| 230 |
+
import core.keychain as keychain
|
| 231 |
+
from routes_keychain import _CONNECTOR_FLAGS_KEY, _resolved_odoo_key
|
| 232 |
+
entries = keychain.list_entries(rt)
|
| 233 |
+
locked = not keychain.unlocked()
|
| 234 |
+
resolved, _flag = _resolved_odoo_key(rt)
|
| 235 |
+
flags = rt.get(_CONNECTOR_FLAGS_KEY) or {}
|
| 236 |
+
except Exception as e: # noqa: BLE001
|
| 237 |
+
return [], f"connectors unreadable ({type(e).__name__})", False
|
| 238 |
+
|
| 239 |
+
rows = []
|
| 240 |
+
if slug == "royal-imports" and os.environ.get("ODOO_URL"):
|
| 241 |
+
rows.append({"tenant": slug, "key": "odoo-env", "label": "Odoo (environment)",
|
| 242 |
+
"type": "odoo", "source": "env", "active": resolved == "env",
|
| 243 |
+
"paused": bool((flags.get("odoo-env") or {}).get("paused"))})
|
| 244 |
+
for e in entries:
|
| 245 |
+
rows.append({"tenant": slug, "key": e["id"], "label": e["label"], "type": e["type"],
|
| 246 |
+
"source": "keychain",
|
| 247 |
+
"active": e["type"] == "odoo" and resolved == f"keychain:{e['id']}",
|
| 248 |
+
"paused": bool((flags.get(e["id"]) or {}).get("paused"))})
|
| 249 |
+
return rows, "", locked
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ══════════════════════════════════════════════════════════════════════════ automations + cost
|
| 253 |
+
|
| 254 |
+
#: THE UNIT MODEL, from `ops/provision_automation_cron.py` (its cost note at :25-28 and the
|
| 255 |
+
#: function it actually provisions at :211 / :269). Restated as constants rather than as prose so
|
| 256 |
+
#: the arithmetic below is re-checkable against the thing that was really deployed:
|
| 257 |
+
#: EventBridge Scheduler `rate(15 minutes)` → a 128 MB, 30 s-timeout Lambda that POSTs the tick.
|
| 258 |
+
TICK_CADENCE = "rate(15 minutes)"
|
| 259 |
+
TICK_PER_DAY = 96 # 1440 / 15
|
| 260 |
+
LAMBDA_MB = 128
|
| 261 |
+
FREE_LAMBDA_REQUESTS = 1_000_000 # AWS always-free, per month
|
| 262 |
+
FREE_SCHEDULER_INVOCATIONS = 14_000_000
|
| 263 |
+
DAYS_PER_MONTH = 30.4
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _runs_per_day(cron):
|
| 267 |
+
"""Scheduled runs/day for the five-field crons this product offers, or None.
|
| 268 |
+
|
| 269 |
+
⚠ DELIBERATELY NARROW. It answers exactly the shapes `automation_engine.CRON_PRESETS` can
|
| 270 |
+
produce (every-N-minutes, hourly, daily, weekly, monthly) and returns None for anything else
|
| 271 |
+
— an unparsed cadence is reported as "custom", never as a guessed number that would then be
|
| 272 |
+
multiplied into a cost. A wrong denominator is worse than an absent one.
|
| 273 |
+
|
| 274 |
+
⚠ AND IT IS NOT MEASURED FROM HISTORY, on purpose: `automation_engine.MAX_RUNS` trims run
|
| 275 |
+
history to 20 entries, so a 15-minute automation retains ~5 hours of it. Deriving runs/day
|
| 276 |
+
from that window would understate by ~5x. History is reported as what it is — the last N runs.
|
| 277 |
+
"""
|
| 278 |
+
parts = str(cron or "").split()
|
| 279 |
+
if len(parts) != 5:
|
| 280 |
+
return None
|
| 281 |
+
minute, hour, dom, _mon, dow = parts
|
| 282 |
+
if minute.startswith("*/") and hour == "*":
|
| 283 |
+
try:
|
| 284 |
+
step = int(minute[2:])
|
| 285 |
+
except ValueError:
|
| 286 |
+
return None
|
| 287 |
+
return (1440.0 / step) if step > 0 else None
|
| 288 |
+
if minute.isdigit() and hour == "*":
|
| 289 |
+
return 24.0
|
| 290 |
+
if minute.isdigit() and hour.isdigit():
|
| 291 |
+
if dom.isdigit():
|
| 292 |
+
return 1.0 / DAYS_PER_MONTH # monthly
|
| 293 |
+
if dow.isdigit():
|
| 294 |
+
return 1.0 / 7.0 # weekly
|
| 295 |
+
return 1.0 # daily
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _automation_rows(slug, rt):
|
| 300 |
+
"""A tenant's automations, their schedules, and their retained run history."""
|
| 301 |
+
try:
|
| 302 |
+
import automation_engine as engine
|
| 303 |
+
defs = engine.all_definitions(rt) or {}
|
| 304 |
+
max_runs = int(getattr(engine, "MAX_RUNS", 20))
|
| 305 |
+
except Exception as e: # noqa: BLE001
|
| 306 |
+
return [], f"automations unreadable ({type(e).__name__})", 20
|
| 307 |
+
rows = []
|
| 308 |
+
for auto_id, d in sorted(defs.items(), key=lambda kv: (kv[1].get("name") or "").lower()):
|
| 309 |
+
if not isinstance(d, dict):
|
| 310 |
+
continue
|
| 311 |
+
sched = d.get("schedule") or {}
|
| 312 |
+
status = d.get("status") or {}
|
| 313 |
+
runs = list(d.get("runs") or [])
|
| 314 |
+
cron = sched.get("cron") or ""
|
| 315 |
+
per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
|
| 316 |
+
rows.append({
|
| 317 |
+
"tenant": slug,
|
| 318 |
+
"id": auto_id,
|
| 319 |
+
"name": d.get("name") or auto_id,
|
| 320 |
+
"kind": d.get("kind") or "",
|
| 321 |
+
"enabled": bool(sched.get("enabled")),
|
| 322 |
+
"cron": cron,
|
| 323 |
+
"runsPerDay": round(per_day, 2) if per_day is not None else None,
|
| 324 |
+
"state": status.get("state") or "idle",
|
| 325 |
+
"lastRunAt": status.get("lastRunAt") or "",
|
| 326 |
+
"lastSummary": status.get("lastSummary") or "",
|
| 327 |
+
# The retained window, named as such — see `_runs_per_day`'s second warning.
|
| 328 |
+
"runsRetained": len(runs),
|
| 329 |
+
"failedRetained": sum(1 for r in runs if isinstance(r, dict) and not r.get("ok")),
|
| 330 |
+
"createdBy": d.get("createdBy") or "",
|
| 331 |
+
"created": d.get("created") or "",
|
| 332 |
+
})
|
| 333 |
+
return rows, "", max_runs
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _automation_cost(auto_rows):
|
| 337 |
+
"""The estimated monthly cost of running the automation fleet — and the honest shape of it.
|
| 338 |
+
|
| 339 |
+
THE POINT THIS BLOCK EXISTS TO MAKE, which is not intuitive: **the external cron does not
|
| 340 |
+
scale with automations or tenants.** One EventBridge schedule fires one Lambda, which POSTs
|
| 341 |
+
one tick, and that tick runs every DUE automation for every tenant (`provision_automation_cron`
|
| 342 |
+
states this as the reason it stays $0 "at ten tenants"). So the AWS bill is a function of the
|
| 343 |
+
CADENCE alone — the fleet below adds work inside the API container, which is already paid for.
|
| 344 |
+
|
| 345 |
+
WHAT IS NOT COMPUTED HERE, deliberately: Lambda GB-seconds. That needs the real average
|
| 346 |
+
duration of the function, which only CloudWatch knows; assuming one would be inventing the
|
| 347 |
+
larger half of the free-tier calculation. `/aws` reports the measured figure when credentials
|
| 348 |
+
are available, and this block says so rather than filling the gap with a plausible number.
|
| 349 |
+
"""
|
| 350 |
+
invocations = TICK_PER_DAY * DAYS_PER_MONTH
|
| 351 |
+
fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
|
| 352 |
+
unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
|
| 353 |
+
return {
|
| 354 |
+
"cadence": TICK_CADENCE,
|
| 355 |
+
"invocationsPerMonth": int(round(invocations)),
|
| 356 |
+
"freeRequestsPct": round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3),
|
| 357 |
+
"freeSchedulerPct": round(100.0 * invocations / FREE_SCHEDULER_INVOCATIONS, 4),
|
| 358 |
+
"lambdaMb": LAMBDA_MB,
|
| 359 |
+
"usd": 0.0,
|
| 360 |
+
"fleetRunsPerDay": round(fleet_runs, 2),
|
| 361 |
+
"unknownCadence": unknown_cadence,
|
| 362 |
+
"basis": (
|
| 363 |
+
f"One EventBridge schedule ({TICK_CADENCE}) fires one {LAMBDA_MB} MB Lambda that "
|
| 364 |
+
f"POSTs the tick; that ONE tick runs every due automation for every tenant, so the "
|
| 365 |
+
f"AWS cost is set by the cadence and does not grow with the fleet. "
|
| 366 |
+
f"{int(round(invocations)):,} invocations/month is "
|
| 367 |
+
f"{round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3)}% of the 1,000,000-request "
|
| 368 |
+
f"always-free tier, so the marginal cost of the automations below is $0.00. "
|
| 369 |
+
f"Compute (GB-seconds) depends on measured durations and is NOT estimated here — "
|
| 370 |
+
f"the AWS report reads the real figure when credentials are available."
|
| 371 |
+
),
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ══════════════════════════════════════════════════════════════════════════ the AWS report
|
| 376 |
+
|
| 377 |
+
#: `ops/aws_usage_report.py`, relative to this file: aios-web/api/ -> repo root -> ops/.
|
| 378 |
+
_AWS_REPORT = Path(__file__).resolve().parents[2] / "ops" / "aws_usage_report.py"
|
| 379 |
+
_AWS_TIMEOUT = 60
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _aws_report(days=7):
|
| 383 |
+
"""The AWS usage report as text, or an honest block saying why there is none.
|
| 384 |
+
|
| 385 |
+
⛔ SUBPROCESS, NEVER `import`. Two concrete reasons, both found by reading that file rather
|
| 386 |
+
than by running it:
|
| 387 |
+
* it reassigns `sys.stdout` to a UTF-8 wrapper AT MODULE IMPORT (a cp1252 fix for this
|
| 388 |
+
box) — importing it would mutate the API process's stdout as a side effect;
|
| 389 |
+
* its `main()` calls `argparse.parse_args()` with no argv, so inside a server it would
|
| 390 |
+
parse UVICORN's arguments and can `sys.exit(2)` — and `SystemExit` is a `BaseException`,
|
| 391 |
+
which `except Exception` does not catch. A route that "cannot crash" would crash.
|
| 392 |
+
|
| 393 |
+
⚠ ON THE SPACE THIS IS THE NORMAL PATH, NOT THE ERROR PATH. `deploy_web.py` ships `api/*.py`
|
| 394 |
+
and `web/`; `ops/` is not in the image, and AWS credentials live in a local `.env` that is
|
| 395 |
+
never deployed. So the honest block below is what the deployed plane shows, and it is written
|
| 396 |
+
to be read by an operator as information ("run it here") rather than as a fault.
|
| 397 |
+
"""
|
| 398 |
+
if not _AWS_REPORT.is_file():
|
| 399 |
+
return {"available": False, "text": "",
|
| 400 |
+
"note": ("The AWS usage report is not part of this deployment — `ops/` ships "
|
| 401 |
+
"with the repository, not with the container image. Run "
|
| 402 |
+
"`python ops/aws_usage_report.py` locally for the live figures.")}
|
| 403 |
+
try:
|
| 404 |
+
proc = subprocess.run(
|
| 405 |
+
[sys.executable, str(_AWS_REPORT), "--days", str(int(days))],
|
| 406 |
+
capture_output=True, text=True, timeout=_AWS_TIMEOUT,
|
| 407 |
+
cwd=str(_AWS_REPORT.parent.parent))
|
| 408 |
+
except subprocess.TimeoutExpired:
|
| 409 |
+
return {"available": False, "text": "",
|
| 410 |
+
"note": (f"The AWS report did not answer within {_AWS_TIMEOUT}s — CloudWatch may "
|
| 411 |
+
f"be unreachable from here. Nothing was assumed about usage.")}
|
| 412 |
+
except Exception as e: # noqa: BLE001
|
| 413 |
+
return {"available": False, "text": "",
|
| 414 |
+
"note": f"The AWS report could not be run here ({type(e).__name__})."}
|
| 415 |
+
out = (proc.stdout or "").strip()
|
| 416 |
+
if proc.returncode != 0 or not out:
|
| 417 |
+
detail = (proc.stderr or "").strip().splitlines()
|
| 418 |
+
return {"available": False, "text": out,
|
| 419 |
+
"note": ("AWS credentials are not available here, or boto3 is not installed — "
|
| 420 |
+
"no usage could be read, and none is guessed. "
|
| 421 |
+
+ (detail[-1][:200] if detail else ""))}
|
| 422 |
+
return {"available": True, "text": out, "note": ""}
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ══════════════════════════════════════════════════════════════════════════ routes
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
@router.get("/overview")
|
| 429 |
+
def overview(session: Session = Depends(padmin_gate)):
|
| 430 |
+
"""THE PLANE'S ONE TABLE: every tenant, with the counts that drill to the rows below.
|
| 431 |
+
|
| 432 |
+
Each count is produced by the same collector its `/…` route serves, so the number and its
|
| 433 |
+
drill-down are one computation projected twice. A subsystem that cannot be read contributes
|
| 434 |
+
an `errors` entry on that tenant's row and a NULL count — never a zero, which would read as
|
| 435 |
+
"this customer has no databases" when the truth is "we could not look".
|
| 436 |
+
"""
|
| 437 |
+
t0 = time.time()
|
| 438 |
+
all_users = _user_rows()
|
| 439 |
+
users_by_tenant = {}
|
| 440 |
+
for u in all_users:
|
| 441 |
+
users_by_tenant.setdefault(u["tenant"], []).append(u)
|
| 442 |
+
|
| 443 |
+
rows = []
|
| 444 |
+
for t, rt in _scan():
|
| 445 |
+
slug = t["slug"]
|
| 446 |
+
row = dict(t)
|
| 447 |
+
row["users"] = len(users_by_tenant.get(slug, []))
|
| 448 |
+
row["admins"] = sum(1 for u in users_by_tenant.get(slug, []) if u["role"] == "admin")
|
| 449 |
+
errors = [t["error"]] if t["error"] else []
|
| 450 |
+
if rt is None:
|
| 451 |
+
# An unresolvable tenant still shows its ACCOUNTS (they live in the global registry,
|
| 452 |
+
# which is readable regardless) — everything tenant-store-shaped is honestly unknown.
|
| 453 |
+
row.update({"databases": None, "rows": None, "connectors": None,
|
| 454 |
+
"automations": None, "keychainLocked": None, "errors": errors})
|
| 455 |
+
rows.append(row)
|
| 456 |
+
continue
|
| 457 |
+
dbs, db_err = _database_rows(slug, rt)
|
| 458 |
+
conns, conn_err, locked = _connector_rows(slug, rt)
|
| 459 |
+
autos, auto_err, _mr = _automation_rows(slug, rt)
|
| 460 |
+
errors += [e for e in (db_err, conn_err, auto_err) if e]
|
| 461 |
+
row.update({
|
| 462 |
+
"databases": None if db_err else len(dbs),
|
| 463 |
+
"rows": None if db_err else sum(d["rowCount"] for d in dbs),
|
| 464 |
+
"connectors": None if conn_err else len(conns),
|
| 465 |
+
"connectorsPaused": None if conn_err else sum(1 for c in conns if c["paused"]),
|
| 466 |
+
"automations": None if auto_err else len(autos),
|
| 467 |
+
"automationsEnabled": None if auto_err else sum(1 for a in autos if a["enabled"]),
|
| 468 |
+
"keychainLocked": None if conn_err else locked,
|
| 469 |
+
"errors": errors,
|
| 470 |
+
})
|
| 471 |
+
rows.append(row)
|
| 472 |
+
|
| 473 |
+
return {
|
| 474 |
+
"tenants": rows,
|
| 475 |
+
"totals": {
|
| 476 |
+
"tenants": len(rows),
|
| 477 |
+
# EVERY account, not the sum of the rows: an account whose `tenant` names a slug this
|
| 478 |
+
# deployment no longer knows belongs in the platform total and would vanish from a
|
| 479 |
+
# per-row sum. `orphanUsers` names that gap instead of hiding it.
|
| 480 |
+
"users": len(all_users),
|
| 481 |
+
"orphanUsers": len(all_users) - sum(r["users"] for r in rows),
|
| 482 |
+
# Sums SKIP unknowns rather than treating them as 0, and say how many were skipped —
|
| 483 |
+
# a total that silently absorbs an unreadable tenant is a fabricated total.
|
| 484 |
+
"databases": sum(r["databases"] or 0 for r in rows),
|
| 485 |
+
"rows": sum(r["rows"] or 0 for r in rows),
|
| 486 |
+
"automations": sum(r["automations"] or 0 for r in rows),
|
| 487 |
+
"unknownTenants": sum(1 for r in rows if r["databases"] is None),
|
| 488 |
+
},
|
| 489 |
+
"storeAvailable": bool(_store_ok()),
|
| 490 |
+
# ⚠ OFFSET-BEARING, and that is not pedantry. The client renders this as "read 2 minutes
|
| 491 |
+
# ago" via `Date.parse`, which reads a NAIVE stamp as browser-local — so a UTC container
|
| 492 |
+
# and a US viewer would turn "just now" into "5 hours ago", or into a future date that
|
| 493 |
+
# renders as "just now" forever. The stamps written by `core.users` carry an offset for
|
| 494 |
+
# the same reason; anything a browser subtracts from `Date.now()` must say what zone it
|
| 495 |
+
# is in. `verify_api` asserts the offset so this cannot regress to `strftime`.
|
| 496 |
+
"generatedAt": _now_iso(),
|
| 497 |
+
"tookMs": int((time.time() - t0) * 1000),
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _store_ok():
|
| 502 |
+
try:
|
| 503 |
+
return store.available()
|
| 504 |
+
except Exception:
|
| 505 |
+
return False
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
@router.get("/users")
|
| 509 |
+
def platform_users(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 510 |
+
"""Every account on the platform, or one tenant's — the drill behind the Users count.
|
| 511 |
+
|
| 512 |
+
R4's stamps are the columns that did not exist before this wave: `lastLogin` is written by
|
| 513 |
+
`routes_auth.login`, `lastActive` by `deps.require_session` (throttled to once an hour per
|
| 514 |
+
account per process). Absent means never seen, and the pane renders it as "never".
|
| 515 |
+
"""
|
| 516 |
+
rows = _user_rows(tenant)
|
| 517 |
+
return {"users": rows, "count": len(rows),
|
| 518 |
+
"stampsNote": ("Login and activity stamps started with this release — accounts that "
|
| 519 |
+
"have not signed in since show no date rather than an invented one. "
|
| 520 |
+
"Activity is recorded at most once an hour per account.")}
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
@router.get("/databases")
|
| 524 |
+
def platform_databases(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 525 |
+
"""Every tenant's user-created databases and row counts — the drill behind Databases/Rows."""
|
| 526 |
+
out, errors = [], {}
|
| 527 |
+
for t, rt in _scan(tenant):
|
| 528 |
+
slug = t["slug"]
|
| 529 |
+
if rt is None:
|
| 530 |
+
errors[slug] = t["error"]
|
| 531 |
+
continue
|
| 532 |
+
rows, err = _database_rows(slug, rt)
|
| 533 |
+
if err:
|
| 534 |
+
errors[slug] = err
|
| 535 |
+
continue
|
| 536 |
+
out += rows
|
| 537 |
+
return {"databases": out, "count": len(out),
|
| 538 |
+
"rows": sum(d["rowCount"] for d in out), "errors": errors}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
@router.get("/connectors")
|
| 542 |
+
def platform_connectors(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 543 |
+
"""Every tenant's data sources, which one is live, and which are paused.
|
| 544 |
+
|
| 545 |
+
METADATA ONLY — `keychain.list_entries` never decrypts, so no credential, and no masked
|
| 546 |
+
preview either: a platform operator needs to know a source EXISTS and whether it serves, not
|
| 547 |
+
what the secret looks like. The tenant's own admin surface is where previews belong.
|
| 548 |
+
"""
|
| 549 |
+
out, errors, locked_any = [], {}, {}
|
| 550 |
+
for t, rt in _scan(tenant):
|
| 551 |
+
slug = t["slug"]
|
| 552 |
+
if rt is None:
|
| 553 |
+
errors[slug] = t["error"]
|
| 554 |
+
continue
|
| 555 |
+
rows, err, locked = _connector_rows(slug, rt)
|
| 556 |
+
if err:
|
| 557 |
+
errors[slug] = err
|
| 558 |
+
continue
|
| 559 |
+
locked_any[slug] = locked
|
| 560 |
+
out += rows
|
| 561 |
+
return {"connectors": out, "count": len(out), "keychainLocked": locked_any,
|
| 562 |
+
"errors": errors,
|
| 563 |
+
"note": ("A locked keychain means this container has no `AIOS_KEYCHAIN_KEY` — stored "
|
| 564 |
+
"credentials cannot be read, so a tenant's sources fail closed rather than "
|
| 565 |
+
"falling back to anyone else's.")}
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
@router.get("/automations")
|
| 569 |
+
def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 570 |
+
"""Every tenant's automations, their schedules and run history, plus the fleet cost model."""
|
| 571 |
+
out, errors, retained = [], {}, 20
|
| 572 |
+
for t, rt in _scan(tenant):
|
| 573 |
+
slug = t["slug"]
|
| 574 |
+
if rt is None:
|
| 575 |
+
errors[slug] = t["error"]
|
| 576 |
+
continue
|
| 577 |
+
rows, err, max_runs = _automation_rows(slug, rt)
|
| 578 |
+
if err:
|
| 579 |
+
errors[slug] = err
|
| 580 |
+
continue
|
| 581 |
+
retained = max_runs
|
| 582 |
+
out += rows
|
| 583 |
+
return {"automations": out, "count": len(out),
|
| 584 |
+
"enabled": sum(1 for a in out if a["enabled"]),
|
| 585 |
+
"historyRetained": retained,
|
| 586 |
+
"cost": _automation_cost(out), "errors": errors,
|
| 587 |
+
"tickEnabled": os.environ.get("AIOS_AUTOMATIONS") == "1"}
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
@router.get("/aws")
|
| 591 |
+
def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
|
| 592 |
+
"""The AWS cron's usage report, verbatim, or an honest note explaining its absence."""
|
| 593 |
+
days = max(1, min(int(days or 7), 90))
|
| 594 |
+
return {"days": days, "report": _aws_report(days)}
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
@router.get("/releases")
|
| 598 |
+
def platform_releases(session: Session = Depends(padmin_gate)):
|
| 599 |
+
"""⭐ WAVE 20 (owner item 12, ruling R6) — WHAT IS RUNNING WHERE, and what else could be.
|
| 600 |
+
|
| 601 |
+
Owner: *"Make this part of the deploy skill. Also ability to revert to any version we want.
|
| 602 |
+
Make sure App versioning is something that our company admin (loopable, non-tenant) can
|
| 603 |
+
easily see."* This is the SEEING half; promoting stays a CLI command by R6, so nothing here
|
| 604 |
+
writes and no web session can move production.
|
| 605 |
+
|
| 606 |
+
⛔ THE HUB READS LIVE IN `core/releases.py`, NOT HERE. `ops/verify_portability.py` B2 forbids
|
| 607 |
+
the HuggingFace SDK anywhere under `aios-web/api/` — the API process is host-agnostic by
|
| 608 |
+
design — and it caught this endpoint's first draft doing exactly that. Same delegation shape
|
| 609 |
+
as `routes_assets` -> `core/assets.py`.
|
| 610 |
+
"""
|
| 611 |
+
import core.releases as releases
|
| 612 |
+
|
| 613 |
+
return {"here": os.environ.get("AIOS_VERSION") or "unknown",
|
| 614 |
+
"environments": releases.environments(),
|
| 615 |
+
"releases": releases.history(),
|
| 616 |
+
# The runbook, in the payload, because the panel is read-only BY DESIGN and a user
|
| 617 |
+
# looking at it is exactly the person who needs to know how to move a version.
|
| 618 |
+
"promote": "python aios-web/deploy_web.py --promote=vN (or --promote=staging)"}
|
api/routes_shares.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_shares.py — the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
|
| 2 |
+
|
| 3 |
+
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
|
| 4 |
+
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
|
| 5 |
+
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
|
| 6 |
+
|
| 7 |
+
`kind` ∈ view | folder | database. Roles are `view` | `edit` — the same two words the view rail
|
| 8 |
+
already speaks, now extended to folders and databases so there is ONE vocabulary in the UI
|
| 9 |
+
(R10: "the same picker views use").
|
| 10 |
+
|
| 11 |
+
⛔ **RE-SHARING IS THE OWNER'S, AND THAT IS ENFORCED HERE, NOT IN THE CLIENT.** `PUT` requires
|
| 12 |
+
`shares.may_administer` (owner or admin). A collaborator with `edit` may change an object's
|
| 13 |
+
CONTENT and may not change who else can reach it — otherwise anyone you shared a view with could
|
| 14 |
+
widen it to everyone, or grant themselves ownership and lock you out. The client greys the editor
|
| 15 |
+
for non-administrators; that is a courtesy, and this check is the wall.
|
| 16 |
+
|
| 17 |
+
⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL.** `*` ("everyone") means every account that can
|
| 18 |
+
already open the surface — `require_session` plus the topic's own gate run first and are
|
| 19 |
+
unaffected by anything here. Sharing can only ever narrow-or-equal the set that could already
|
| 20 |
+
reach the data ([[aios-permissioning]]).
|
| 21 |
+
"""
|
| 22 |
+
from fastapi import APIRouter, Body, Depends
|
| 23 |
+
|
| 24 |
+
import core.shares as shares
|
| 25 |
+
import core.users as users
|
| 26 |
+
from deps import Session, err, require_session
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/api/v1")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _kind_or_400(raw):
|
| 32 |
+
try:
|
| 33 |
+
return shares._check_kind(raw)
|
| 34 |
+
except ValueError as e:
|
| 35 |
+
raise err(400, "bad_kind", str(e))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/share/mine")
|
| 39 |
+
def my_shares(session: Session = Depends(require_session)):
|
| 40 |
+
"""Everything shared WITH me, by kind — the "Shared with me" rail section (R10).
|
| 41 |
+
|
| 42 |
+
Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
|
| 43 |
+
in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
|
| 44 |
+
that is not malformed at all.
|
| 45 |
+
"""
|
| 46 |
+
return shares.shared_with(session.uname, st=session.runtime)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/share/{kind}/{oid}")
|
| 50 |
+
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
| 51 |
+
kind = _kind_or_400(kind)
|
| 52 |
+
rec = shares.grants(kind, oid, st=session.runtime)
|
| 53 |
+
return {
|
| 54 |
+
**rec,
|
| 55 |
+
"role": shares.role_for(kind, oid, session.uname, is_admin=session.admin,
|
| 56 |
+
st=session.runtime),
|
| 57 |
+
"mayAdminister": shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
| 58 |
+
st=session.runtime),
|
| 59 |
+
# The people this account may share WITH — reusing the ONE definition of an assignable
|
| 60 |
+
# person (`core.users.assignable_people`, the same list the `user` field kind offers), so
|
| 61 |
+
# the share picker and the assignee picker can never disagree about who exists.
|
| 62 |
+
"people": users.assignable_people(tenant=session.tenant),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.put("/share/{kind}/{oid}")
|
| 67 |
+
def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
| 68 |
+
session: Session = Depends(require_session)):
|
| 69 |
+
kind = _kind_or_400(kind)
|
| 70 |
+
body = body or {}
|
| 71 |
+
rec = shares.grants(kind, oid, st=session.runtime)
|
| 72 |
+
# An object with NO grant record yet has no owner — the first person to share it claims it.
|
| 73 |
+
# That is safe because reaching this route at all means passing the surface's own wall, and
|
| 74 |
+
# the alternative (refusing until somebody seeds an owner) would make a brand-new folder
|
| 75 |
+
# unshareable by the person who just made it.
|
| 76 |
+
if rec["owner"] and not shares.may_administer(kind, oid, session.uname,
|
| 77 |
+
is_admin=session.admin, st=session.runtime):
|
| 78 |
+
raise err(403, "not_owner",
|
| 79 |
+
"only the owner of this item (or an administrator) can change who it is "
|
| 80 |
+
"shared with")
|
| 81 |
+
entries = body.get("entries")
|
| 82 |
+
if not isinstance(entries, list):
|
| 83 |
+
raise err(400, "bad_entries",
|
| 84 |
+
"entries must be a list of {user, role} — send [] to un-share, which is how "
|
| 85 |
+
"revoking is expressed")
|
| 86 |
+
return shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
|
| 87 |
+
st=session.runtime)
|
api/routes_tables.py
CHANGED
|
@@ -107,16 +107,42 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
|
| 107 |
"defn": defn}
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
@router.get("/tables")
|
| 111 |
def list_tables(session: Session = Depends(require_session)):
|
| 112 |
"""This session's user tables — the list the '+ New database' surface renders."""
|
| 113 |
ut = _ut()
|
|
|
|
| 114 |
out = []
|
| 115 |
for key, t in sorted(ut.all_tables(st=session.runtime).items(),
|
| 116 |
-
key=lambda kv: (kv[1]
|
| 117 |
if not ut.may_open(key, session.uname, session.admin, st=session.runtime):
|
| 118 |
continue
|
| 119 |
-
out.append({"key": key, "label":
|
| 120 |
"source": t.get("source") or "Blank",
|
| 121 |
"createdBy": t.get("createdBy") or "",
|
| 122 |
"created": t.get("created") or "",
|
|
@@ -150,10 +176,40 @@ def create_table(body: dict = Body(default=None),
|
|
| 150 |
return {"key": key}
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
@router.delete("/tables/{table_key}")
|
| 154 |
def delete_table(table_key: str, session: Session = Depends(require_session)):
|
| 155 |
"""Creator or admin only — the same actors `may_open` admits, and deletion is the one
|
| 156 |
-
operation the client must confirm explicitly (the server cannot tell a click from a plan).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
_defn_or_refuse(session, table_key)
|
| 158 |
try:
|
| 159 |
_ut().delete(table_key, st=session.runtime)
|
|
@@ -194,13 +250,20 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
|
|
| 194 |
@router.post("/tables/{table_key}/rows", status_code=201)
|
| 195 |
def add_row(table_key: str, body: dict = Body(default=None),
|
| 196 |
session: Session = Depends(require_session)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
_defn_or_refuse(session, table_key)
|
| 198 |
ut = _ut()
|
| 199 |
values = (body or {}).get("values") or {}
|
| 200 |
if not isinstance(values, dict):
|
| 201 |
raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
|
| 202 |
try:
|
| 203 |
-
rid = ut.add_row(table_key, values, session.uname, st=session.runtime
|
|
|
|
| 204 |
except Exception:
|
| 205 |
raise err(503, "store_unavailable", "the row was not saved — the store refused")
|
| 206 |
if rid is None:
|
|
@@ -209,6 +272,100 @@ def add_row(table_key: str, body: dict = Body(default=None),
|
|
| 209 |
return {"rid": rid, "pid": int(rid)}
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
@router.delete("/tables/{table_key}/rows/{rid}")
|
| 213 |
def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
|
| 214 |
_defn_or_refuse(session, table_key)
|
|
|
|
| 107 |
"defn": defn}
|
| 108 |
|
| 109 |
|
| 110 |
+
def ut_label(defn, key, meta=None):
|
| 111 |
+
"""THE name of a user table, resolved ONCE (wave 20, item 6a).
|
| 112 |
+
|
| 113 |
+
`nav_meta`'s rename wins, then the definition's own label, then the key. Every surface that
|
| 114 |
+
shows a database name reads through here, because the alternative is what wave 20 found: the
|
| 115 |
+
rail showed the renamed name (it reads `nav_meta`) while the automation editor's picker
|
| 116 |
+
showed the original (it reads the definition), and neither looked broken.
|
| 117 |
+
|
| 118 |
+
⚠ `set_label` now writes the DEFINITION too, so the two agree at the source. This resolver
|
| 119 |
+
stays because it makes every row already stored — renamed before that fix landed — read
|
| 120 |
+
correctly today, without a migration.
|
| 121 |
+
"""
|
| 122 |
+
return ((meta or {}).get(key, {}).get("name")
|
| 123 |
+
or (defn or {}).get("label") or key)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def nav_meta(session):
|
| 127 |
+
"""The tenant's nav_meta bucket, read defensively. A store blip must not take a list down."""
|
| 128 |
+
try:
|
| 129 |
+
got = session.runtime.get("nav_meta")
|
| 130 |
+
return got if isinstance(got, dict) else {}
|
| 131 |
+
except Exception: # noqa: BLE001
|
| 132 |
+
return {}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
@router.get("/tables")
|
| 136 |
def list_tables(session: Session = Depends(require_session)):
|
| 137 |
"""This session's user tables — the list the '+ New database' surface renders."""
|
| 138 |
ut = _ut()
|
| 139 |
+
meta = nav_meta(session)
|
| 140 |
out = []
|
| 141 |
for key, t in sorted(ut.all_tables(st=session.runtime).items(),
|
| 142 |
+
key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
|
| 143 |
if not ut.may_open(key, session.uname, session.admin, st=session.runtime):
|
| 144 |
continue
|
| 145 |
+
out.append({"key": key, "label": ut_label(t, key, meta),
|
| 146 |
"source": t.get("source") or "Blank",
|
| 147 |
"createdBy": t.get("createdBy") or "",
|
| 148 |
"created": t.get("created") or "",
|
|
|
|
| 176 |
return {"key": key}
|
| 177 |
|
| 178 |
|
| 179 |
+
@router.patch("/tables/{table_key}")
|
| 180 |
+
def patch_table(table_key: str, body: dict = Body(default=None),
|
| 181 |
+
session: Session = Depends(require_session)):
|
| 182 |
+
"""Rename a database — IN ITS DEFINITION (wave 20, item 6a).
|
| 183 |
+
|
| 184 |
+
⚠ THE RENAME DOOR IN THE NAV WRITES `nav_meta` AND MUST ALSO CALL THIS. `nav_meta` is the
|
| 185 |
+
nav's display layer; the definition is what the automation editor's database picker, the
|
| 186 |
+
schema drawer and every future reader see. A rename that lands in only one of them leaves a
|
| 187 |
+
picker that is confidently wrong rather than obviously stale. Posted to the wave doc as an
|
| 188 |
+
amendment for whoever owns that door.
|
| 189 |
+
"""
|
| 190 |
+
defn = _defn_or_refuse(session, table_key)
|
| 191 |
+
ut = _ut()
|
| 192 |
+
if not (session.admin or defn.get("createdBy") == session.uname):
|
| 193 |
+
raise err(403, "forbidden", "only the database's creator or an admin can rename it")
|
| 194 |
+
label = ut.set_label(table_key, (body or {}).get("label"), st=session.runtime)
|
| 195 |
+
if not label:
|
| 196 |
+
raise err(400, "bad_label", "give the database a name")
|
| 197 |
+
return {"key": table_key, "label": label}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
@router.delete("/tables/{table_key}")
|
| 201 |
def delete_table(table_key: str, session: Session = Depends(require_session)):
|
| 202 |
"""Creator or admin only — the same actors `may_open` admits, and deletion is the one
|
| 203 |
+
operation the client must confirm explicitly (the server cannot tell a click from a plan).
|
| 204 |
+
|
| 205 |
+
⚠ WAVE 20, item 3 — THE OWNER'S "an admin cannot delete a database" IS NOT A WALL BUG HERE.
|
| 206 |
+
Measured: **no client calls this route at all.** The React shell POSTs `/tables` to create
|
| 207 |
+
one and has no delete affordance anywhere, so the endpoint is live, correct and unreachable.
|
| 208 |
+
The wall's real defect was next door and is fixed: `routes_automation` carried a SECOND,
|
| 209 |
+
wider ownership rule, and the engine stamped `createdBy: "scheduler"` on tables a cron
|
| 210 |
+
created first — which made a table's owner depend on a race, and left it admin-only forever
|
| 211 |
+
after. See `user_tables.MACHINE_OWNERS` and `automation_engine.ut_ensure`.
|
| 212 |
+
"""
|
| 213 |
_defn_or_refuse(session, table_key)
|
| 214 |
try:
|
| 215 |
_ut().delete(table_key, st=session.runtime)
|
|
|
|
| 250 |
@router.post("/tables/{table_key}/rows", status_code=201)
|
| 251 |
def add_row(table_key: str, body: dict = Body(default=None),
|
| 252 |
session: Session = Depends(require_session)):
|
| 253 |
+
"""Append a row — or RESTORE one under its old id (contract C-ADDROW / C-UNDO).
|
| 254 |
+
|
| 255 |
+
⚠ THE ANSWER IS ALWAYS THE ID THAT WAS STORED, never the one that was asked for. An undo
|
| 256 |
+
that requested `rid: 7` and got 12 because 7 had been re-used must find that out from the
|
| 257 |
+
response rather than assume; the client re-anchors on what came back.
|
| 258 |
+
"""
|
| 259 |
_defn_or_refuse(session, table_key)
|
| 260 |
ut = _ut()
|
| 261 |
values = (body or {}).get("values") or {}
|
| 262 |
if not isinstance(values, dict):
|
| 263 |
raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
|
| 264 |
try:
|
| 265 |
+
rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
|
| 266 |
+
rid=(body or {}).get("rid"))
|
| 267 |
except Exception:
|
| 268 |
raise err(503, "store_unavailable", "the row was not saved — the store refused")
|
| 269 |
if rid is None:
|
|
|
|
| 272 |
return {"rid": rid, "pid": int(rid)}
|
| 273 |
|
| 274 |
|
| 275 |
+
# ---------------------------------------------------------------------------------------------
|
| 276 |
+
# THE SHARED FIELD SCHEMA (contract C-FIELD, owner ruling R2)
|
| 277 |
+
# ---------------------------------------------------------------------------------------------
|
| 278 |
+
# R2: a `ut_*` table's fields are the TABLE'S schema — everyone with access sees the same
|
| 279 |
+
# columns, the creator or an admin edits them, and a per-field `editRole` can open ONE column's
|
| 280 |
+
# definition to everyone without handing over the table. This supersedes wave 17's "fields are
|
| 281 |
+
# per-user" law for this path only; the connector scopes keep their own model.
|
| 282 |
+
#
|
| 283 |
+
# ⚠ WHY IT MATTERS BEYOND TIDINESS: a grid add-field on a `ut_` scope used to land in the
|
| 284 |
+
# per-user workspace overlay, which is why the automation editor's "Automation column" picker
|
| 285 |
+
# could not see a column the user had just created — it reads the DEFINITION. Same defect shape
|
| 286 |
+
# as the rename (item 6a): two places to look, and the surfaces disagreed silently.
|
| 287 |
+
|
| 288 |
+
def _field_or_refuse(session, table_key, fkey=""):
|
| 289 |
+
"""The schema wall. `_defn_or_refuse` first (404/403 on the table), then the per-field rule."""
|
| 290 |
+
defn = _defn_or_refuse(session, table_key)
|
| 291 |
+
ut = _ut()
|
| 292 |
+
if not ut.is_user_table(table_key, st=session.runtime):
|
| 293 |
+
raise err(400, "not_a_user_table",
|
| 294 |
+
"only a user-created database has an editable schema — a connected source "
|
| 295 |
+
"owns its own columns")
|
| 296 |
+
if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
|
| 297 |
+
st=session.runtime):
|
| 298 |
+
raise err(403, "forbidden", "that column can only be changed by the database's creator "
|
| 299 |
+
"or an admin")
|
| 300 |
+
if not fkey and not (session.admin or defn.get("createdBy") == session.uname):
|
| 301 |
+
raise err(403, "forbidden", "only the database's creator or an admin can add a column")
|
| 302 |
+
return defn
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@router.post("/tables/{table_key}/fields", status_code=201)
|
| 306 |
+
def add_field(table_key: str, body: dict = Body(default=None),
|
| 307 |
+
session: Session = Depends(require_session)):
|
| 308 |
+
_field_or_refuse(session, table_key)
|
| 309 |
+
ut = _ut()
|
| 310 |
+
field = ut.add_field(table_key, body or {}, st=session.runtime)
|
| 311 |
+
if not field:
|
| 312 |
+
raise err(400, "refused",
|
| 313 |
+
f"the column was refused — check the name and type, or the table may be at "
|
| 314 |
+
f"its {ut.MAX_FIELDS}-column cap (types: "
|
| 315 |
+
f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 316 |
+
return {"field": field}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.patch("/tables/{table_key}/fields/{fkey}")
|
| 320 |
+
def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
| 321 |
+
session: Session = Depends(require_session)):
|
| 322 |
+
"""Edit one column's definition, and MIGRATE its values when options are renamed.
|
| 323 |
+
|
| 324 |
+
⛔ A CHOICE RENAME IS AN EXPLICIT MAPPING, NEVER A DIFF (contract C-RENAME). `{renames:
|
| 325 |
+
[{from, to}]}` arrives alongside the new options list, because a diff cannot tell "renamed
|
| 326 |
+
Blue to Navy" from "deleted Blue, added Navy" — and guessing wrong empties the column and
|
| 327 |
+
every saved view that filtered on it.
|
| 328 |
+
"""
|
| 329 |
+
_field_or_refuse(session, table_key, fkey)
|
| 330 |
+
ut = _ut()
|
| 331 |
+
body = body or {}
|
| 332 |
+
migrated = None
|
| 333 |
+
renames = body.get("renames")
|
| 334 |
+
if renames:
|
| 335 |
+
try:
|
| 336 |
+
migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
|
| 337 |
+
except Exception:
|
| 338 |
+
raise err(503, "store_unavailable", "the rename did not land — try again")
|
| 339 |
+
# The per-user workspace strata and any view filter naming the old value are the OTHER
|
| 340 |
+
# half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
|
| 341 |
+
# enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
|
| 342 |
+
# missing counterpart must not lose the half that DID land.
|
| 343 |
+
try:
|
| 344 |
+
import core.table_store as table_store
|
| 345 |
+
fn = getattr(table_store, "rename_choice_values", None)
|
| 346 |
+
if callable(fn):
|
| 347 |
+
fn(table_key, fkey, renames, st=session.runtime)
|
| 348 |
+
migrated = dict(migrated or {}, workspace=True)
|
| 349 |
+
except Exception: # noqa: BLE001
|
| 350 |
+
migrated = dict(migrated or {}, workspace=False)
|
| 351 |
+
field = ut.patch_field(table_key, fkey, body, st=session.runtime)
|
| 352 |
+
if not field:
|
| 353 |
+
raise err(400, "refused", "that column could not be changed — check the name and type")
|
| 354 |
+
out = {"field": field}
|
| 355 |
+
if migrated is not None:
|
| 356 |
+
out["migrated"] = migrated
|
| 357 |
+
return out
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@router.delete("/tables/{table_key}/fields/{fkey}")
|
| 361 |
+
def delete_field(table_key: str, fkey: str, session: Session = Depends(require_session)):
|
| 362 |
+
_field_or_refuse(session, table_key, fkey)
|
| 363 |
+
if not _ut().delete_field(table_key, fkey, st=session.runtime):
|
| 364 |
+
raise err(400, "refused",
|
| 365 |
+
"that column could not be removed — a database must keep at least one")
|
| 366 |
+
return {"deleted": fkey}
|
| 367 |
+
|
| 368 |
+
|
| 369 |
@router.delete("/tables/{table_key}/rows/{rid}")
|
| 370 |
def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
|
| 371 |
_defn_or_refuse(session, table_key)
|
platform/core/alerts.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/alerts.py — "tell me when a record ENTERS this view" (wave 20, owner item 25).
|
| 2 |
+
|
| 3 |
+
THE OWNER'S SHAPE, verbatim: *"a user click a view and click 'Create alert'. This way when a
|
| 4 |
+
Record gets into that Filter's criteria, a user gets notified in that module … Read/Unread like an
|
| 5 |
+
inbox type of thing so they can confirm whether they have seen the Record that got filtered in."*
|
| 6 |
+
|
| 7 |
+
So an alert is **a view plus a remembered MATCHED SET**, and a notification is a **new entrant** —
|
| 8 |
+
a pid that matches now and did not last time. That definition is the whole design, and it is what
|
| 9 |
+
makes the module trustworthy rather than noisy:
|
| 10 |
+
|
| 11 |
+
* a record already in the view when the alert is created is NOT news. The first evaluation
|
| 12 |
+
SEEDS the set silently (`seed=True`); without that, creating an alert on a 400-row view
|
| 13 |
+
would announce 400 "new" records, and the user would turn the feature off in one click;
|
| 14 |
+
* a record that leaves and comes back IS news again — it re-entered the criteria, which is the
|
| 15 |
+
event the owner described;
|
| 16 |
+
* a record that merely CHANGES while staying in the view is not an entrant. "Still matching" is
|
| 17 |
+
not an event. (An on-change alert is a different feature with a different noise budget; it is
|
| 18 |
+
NOT smuggled in here under the same name.)
|
| 19 |
+
|
| 20 |
+
⚠ **WHY THE MATCHED SET IS STORED AND NOT RECOMPUTED FROM HISTORY.** There is no row-level audit
|
| 21 |
+
log to diff against — `store` holds current state, and Odoo is read-only. The remembered set IS
|
| 22 |
+
the history, so it is written on every evaluation, in the same transaction that queues the
|
| 23 |
+
notifications. Losing that write while keeping the notifications would re-announce the same
|
| 24 |
+
records forever.
|
| 25 |
+
|
| 26 |
+
⚠ **WHAT AN UNANSWERABLE LEAF ACTUALLY DOES HERE — CHECKED, NOT ASSUMED.** `harness.filter_eval`'s
|
| 27 |
+
`EvalCtx` is explicit that each of its four members "absent, the condition matches NOTHING rather
|
| 28 |
+
than everything" — the engine NARROWS on an unresolved measure/cohort/rank set rather than
|
| 29 |
+
widening. So the failure mode is not a false alert; it is a **missed** one, and a missed entrant
|
| 30 |
+
is re-detected on the next evaluation because `matched` only ever records what genuinely matched.
|
| 31 |
+
|
| 32 |
+
That is the safe direction, and it is why `partial` is a caller-supplied flag rather than
|
| 33 |
+
something inferred in here: the caller (`routes_alerts`) is the only layer that knows whether it
|
| 34 |
+
could BUILD a complete context. When it says so, `evaluate()` records the attempt and changes
|
| 35 |
+
nothing — updating `matched` from a narrowed evaluation would drop rows out of the remembered set
|
| 36 |
+
and then re-announce every one of them as an "entrant" on the next complete pass. That is the
|
| 37 |
+
real hazard: not a phantom alert, but a storm of stale ones.
|
| 38 |
+
|
| 39 |
+
⚠ **EVERY STAMP CARRIES A UTC OFFSET** (DEBT D-18). A browser can only render a timestamp
|
| 40 |
+
relatively if it can subtract it from `now`, and a naive container-local stamp is unsubtractable —
|
| 41 |
+
it renders VERBATIM or, worse, silently as the reader's own zone. `_now_iso()` is the one place
|
| 42 |
+
that is decided.
|
| 43 |
+
"""
|
| 44 |
+
import datetime as _dt
|
| 45 |
+
|
| 46 |
+
import core.store as store
|
| 47 |
+
|
| 48 |
+
#: Alert DEFINITIONS, per tenant: {alert_id: {...}}. Separate from the notifications bucket so a
|
| 49 |
+
#: busy inbox never rewrites the definitions (and a definition edit never rewrites the inbox).
|
| 50 |
+
ALERTS_KEY = 'alerts'
|
| 51 |
+
#: The inbox: {username: [notification, ...]}, newest last.
|
| 52 |
+
NOTIFICATIONS_KEY = 'alert_notifications'
|
| 53 |
+
|
| 54 |
+
#: Per user. An inbox is a WORKING queue, not an archive — the store is a JSON blob read whole on
|
| 55 |
+
#: every request, so an unbounded inbox is a payload that grows without limit for a user who
|
| 56 |
+
#: never clicks. Oldest READ entries are dropped first; unread ones survive the cap because the
|
| 57 |
+
#: whole point is that the user has not seen them yet.
|
| 58 |
+
MAX_NOTIFICATIONS = 200
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _now_iso():
|
| 62 |
+
"""UTC, WITH the offset — `2026-08-05T09:41:07.123456+00:00`. See the module note (D-18)."""
|
| 63 |
+
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _st(st):
|
| 67 |
+
return st if st is not None else store
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def list_alerts(user=None, st=None, is_admin=False):
|
| 71 |
+
"""Alert definitions this user may see: their own. Admins see all (they support them)."""
|
| 72 |
+
try:
|
| 73 |
+
recs = _st(st).get(ALERTS_KEY) or {}
|
| 74 |
+
except Exception:
|
| 75 |
+
return []
|
| 76 |
+
out = []
|
| 77 |
+
for aid, rec in recs.items():
|
| 78 |
+
if not isinstance(rec, dict):
|
| 79 |
+
continue
|
| 80 |
+
if user and not is_admin and str(rec.get('owner') or '') != str(user):
|
| 81 |
+
continue
|
| 82 |
+
out.append({**rec, 'id': str(aid)})
|
| 83 |
+
return sorted(out, key=lambda r: str(r.get('createdAt') or ''))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def create(alert_id, *, view_id, topic, owner, label='', st=None):
|
| 87 |
+
"""Register an alert on a view. Returns the record.
|
| 88 |
+
|
| 89 |
+
The view is stored BY ID, never by a copy of its filter tree. An alert whose criteria were
|
| 90 |
+
snapshotted at creation would silently stop matching the view the moment its owner edited it —
|
| 91 |
+
and the user's mental model is "alert me on THIS VIEW", not "on this view as it was in
|
| 92 |
+
August".
|
| 93 |
+
"""
|
| 94 |
+
rec = {'id': str(alert_id), 'viewId': str(view_id), 'topic': str(topic),
|
| 95 |
+
'owner': str(owner), 'label': str(label or '')[:160],
|
| 96 |
+
'createdAt': _now_iso(), 'matched': [], 'seeded': False,
|
| 97 |
+
'lastRunAt': None, 'lastError': None}
|
| 98 |
+
|
| 99 |
+
def _apply(data):
|
| 100 |
+
data[str(alert_id)] = rec
|
| 101 |
+
return data
|
| 102 |
+
|
| 103 |
+
_st(st).update(ALERTS_KEY, _apply, flush='sync')
|
| 104 |
+
return rec
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def delete(alert_id, st=None):
|
| 108 |
+
def _apply(data):
|
| 109 |
+
data.pop(str(alert_id), None)
|
| 110 |
+
return data
|
| 111 |
+
|
| 112 |
+
_st(st).update(ALERTS_KEY, _apply, flush='sync')
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def evaluate(alert_id, matching_pids, *, labels=None, partial=False, st=None):
|
| 117 |
+
"""Fold a fresh evaluation of one alert's view into its state; queue any NEW entrants.
|
| 118 |
+
|
| 119 |
+
`matching_pids` is what the view matches NOW — the caller owns running the filter, because
|
| 120 |
+
only it knows the topic's rows and the reader's scope. `partial` says the evaluation could not
|
| 121 |
+
answer every leaf; see the module note on why that suppresses everything.
|
| 122 |
+
|
| 123 |
+
Returns `{'new': [...], 'seeded': bool}` or `{'skipped': 'partial'|'missing'}`.
|
| 124 |
+
"""
|
| 125 |
+
now_set = {str(p) for p in (matching_pids or ())}
|
| 126 |
+
outcome = {}
|
| 127 |
+
|
| 128 |
+
def _apply(data):
|
| 129 |
+
rec = data.get(str(alert_id))
|
| 130 |
+
if not isinstance(rec, dict):
|
| 131 |
+
outcome['skipped'] = 'missing'
|
| 132 |
+
return data
|
| 133 |
+
if partial:
|
| 134 |
+
# Record the ATTEMPT (so "last checked" is honest) but change nothing else. Writing
|
| 135 |
+
# `matched` here would bake a widened set in as truth, and the next COMPLETE
|
| 136 |
+
# evaluation would then report every genuinely-absent row as an entrant.
|
| 137 |
+
rec['lastRunAt'] = _now_iso()
|
| 138 |
+
rec['lastError'] = ('the view could not be fully evaluated (a filter leaf was '
|
| 139 |
+
'unanswerable); no alert was raised')
|
| 140 |
+
outcome['skipped'] = 'partial'
|
| 141 |
+
data[str(alert_id)] = rec
|
| 142 |
+
return data
|
| 143 |
+
prior = {str(p) for p in (rec.get('matched') or ())}
|
| 144 |
+
seeding = not rec.get('seeded')
|
| 145 |
+
entrants = [] if seeding else sorted(now_set - prior, key=lambda s: (len(s), s))
|
| 146 |
+
rec['matched'] = sorted(now_set, key=lambda s: (len(s), s))
|
| 147 |
+
rec['seeded'] = True
|
| 148 |
+
rec['lastRunAt'] = _now_iso()
|
| 149 |
+
rec['lastError'] = None
|
| 150 |
+
data[str(alert_id)] = rec
|
| 151 |
+
outcome['new'] = entrants
|
| 152 |
+
outcome['seeded'] = seeding
|
| 153 |
+
outcome['_rec'] = rec
|
| 154 |
+
return data
|
| 155 |
+
|
| 156 |
+
_st(st).update(ALERTS_KEY, _apply, flush='sync')
|
| 157 |
+
if outcome.get('skipped'):
|
| 158 |
+
return {'skipped': outcome['skipped']}
|
| 159 |
+
rec = outcome.pop('_rec', {}) or {}
|
| 160 |
+
if outcome.get('new'):
|
| 161 |
+
_queue(rec, outcome['new'], labels or {}, st=st)
|
| 162 |
+
return outcome
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _queue(rec, pids, labels, st=None):
|
| 166 |
+
"""Append one notification per entrant to the alert owner's inbox."""
|
| 167 |
+
at = _now_iso()
|
| 168 |
+
items = [{'id': f"{rec.get('id')}:{pid}:{at}",
|
| 169 |
+
'alertId': str(rec.get('id')), 'viewId': str(rec.get('viewId')),
|
| 170 |
+
'topic': str(rec.get('topic')), 'rowId': str(pid),
|
| 171 |
+
'label': str(labels.get(str(pid)) or labels.get(pid) or pid),
|
| 172 |
+
'alertLabel': str(rec.get('label') or ''),
|
| 173 |
+
'at': at, 'read': False}
|
| 174 |
+
for pid in pids]
|
| 175 |
+
owner = str(rec.get('owner') or '')
|
| 176 |
+
|
| 177 |
+
def _apply(data):
|
| 178 |
+
inbox = list(data.get(owner) or [])
|
| 179 |
+
inbox.extend(items)
|
| 180 |
+
if len(inbox) > MAX_NOTIFICATIONS:
|
| 181 |
+
# Drop READ entries oldest-first; keep every unread one. A cap that dropped unread
|
| 182 |
+
# notifications would silently lose exactly the records the user has not confirmed —
|
| 183 |
+
# the one thing this module promises not to do.
|
| 184 |
+
unread = [n for n in inbox if not n.get('read')]
|
| 185 |
+
read = [n for n in inbox if n.get('read')]
|
| 186 |
+
keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
|
| 187 |
+
inbox = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
|
| 188 |
+
data[owner] = inbox
|
| 189 |
+
return data
|
| 190 |
+
|
| 191 |
+
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def inbox(user, st=None, limit=100):
|
| 195 |
+
"""`{'unread': int, 'items': [...]}` — newest first, for the badge and the pane."""
|
| 196 |
+
try:
|
| 197 |
+
items = list((_st(st).get(NOTIFICATIONS_KEY) or {}).get(str(user)) or [])
|
| 198 |
+
except Exception:
|
| 199 |
+
return {'unread': 0, 'items': []}
|
| 200 |
+
items = [n for n in items if isinstance(n, dict)]
|
| 201 |
+
items.sort(key=lambda n: str(n.get('at') or ''), reverse=True)
|
| 202 |
+
return {'unread': sum(1 for n in items if not n.get('read')), 'items': items[:max(0, limit)]}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def mark_read(user, ids, read=True, st=None):
|
| 206 |
+
"""Mark specific notifications read/unread. `ids=None` marks every one (mark-all-read)."""
|
| 207 |
+
want = None if ids is None else {str(i) for i in ids}
|
| 208 |
+
|
| 209 |
+
def _apply(data):
|
| 210 |
+
inbox_ = [dict(n) for n in (data.get(str(user)) or []) if isinstance(n, dict)]
|
| 211 |
+
for n in inbox_:
|
| 212 |
+
if want is None or str(n.get('id')) in want:
|
| 213 |
+
n['read'] = bool(read)
|
| 214 |
+
data[str(user)] = inbox_
|
| 215 |
+
return data
|
| 216 |
+
|
| 217 |
+
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
|
| 218 |
+
return inbox(user, st=st)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def after_write(topic_key, st=None, runner=None):
|
| 222 |
+
"""The write-path HOOK: re-evaluate every alert whose view belongs to `topic_key`.
|
| 223 |
+
|
| 224 |
+
⚠ CALLED FROM A WRITE PATH, SO IT MUST NOT RAISE AND MUST NOT BLOCK ON A FULL POOL BUILD.
|
| 225 |
+
A failed alert evaluation must never fail the edit that triggered it — the user typed into a
|
| 226 |
+
cell; whether an alert fires is not their problem. `runner` is injected by the caller
|
| 227 |
+
(`routes_alerts` passes one that can resolve the topic's rows for the alert's owner), so this
|
| 228 |
+
module never reaches for a pool itself and stays unit-testable with a fake.
|
| 229 |
+
"""
|
| 230 |
+
if runner is None:
|
| 231 |
+
return {'evaluated': 0}
|
| 232 |
+
n = 0
|
| 233 |
+
for rec in list_alerts(st=st):
|
| 234 |
+
if str(rec.get('topic')) != str(topic_key):
|
| 235 |
+
continue
|
| 236 |
+
try:
|
| 237 |
+
runner(rec)
|
| 238 |
+
n += 1
|
| 239 |
+
except Exception: # noqa: BLE001 — see the docstring
|
| 240 |
+
continue
|
| 241 |
+
return {'evaluated': n}
|
platform/core/grid_events.py
CHANGED
|
@@ -1063,7 +1063,7 @@ def handle_one(event, ctx):
|
|
| 1063 |
return True
|
| 1064 |
|
| 1065 |
if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
|
| 1066 |
-
'item_move'):
|
| 1067 |
# FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five
|
| 1068 |
# events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort
|
| 1069 |
# can be filed without the cohort STORE learning about folders at all.
|
|
@@ -1123,6 +1123,37 @@ def handle_one(event, ctx):
|
|
| 1123 |
# an item with no placement renders at the top level.
|
| 1124 |
placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
|
| 1125 |
if v != fid}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
elif kind == 'folder_duplicate':
|
| 1127 |
new_id = str(event.get('newId') or '').strip()[:80]
|
| 1128 |
src = next((f for f in lst if f['id'] == fid), None)
|
|
@@ -1193,6 +1224,125 @@ def handle_one(event, ctx):
|
|
| 1193 |
# invisible to the next render (the values leave WITH the column).
|
| 1194 |
return False
|
| 1195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
if kind == 'field_duplicate':
|
| 1197 |
# Wave-5 item 1. The DESTINATION key is client-generated (the established pattern —
|
| 1198 |
# the client must know it synchronously to place the clone right of the source), and
|
|
|
|
| 1063 |
return True
|
| 1064 |
|
| 1065 |
if kind in ('folder_create', 'folder_rename', 'folder_delete', 'folder_duplicate',
|
| 1066 |
+
'item_move', 'folder_reorder'):
|
| 1067 |
# FOLDERS over the Views / Cohorts sidebars (owner item 11, contract C4). All five
|
| 1068 |
# events touch ONE home — the table workspace's `folders` + `itemFolders` — so a cohort
|
| 1069 |
# can be filed without the cohort STORE learning about folders at all.
|
|
|
|
| 1123 |
# an item with no placement renders at the top level.
|
| 1124 |
placed[surface] = {k: v for k, v in (placed.get(surface) or {}).items()
|
| 1125 |
if v != fid}
|
| 1126 |
+
elif kind == 'folder_reorder':
|
| 1127 |
+
# ⭐ WAVE 20 (owner item 19, contract C-FOLDER-REORDER) — the user's own folder
|
| 1128 |
+
# ORDER in the rail. Views could already be dragged between folders; the folders
|
| 1129 |
+
# themselves could not be dragged past each other.
|
| 1130 |
+
#
|
| 1131 |
+
# THE WIRE CARRIES THE FULL ORDER, NEVER A DELTA, and that is the contract's
|
| 1132 |
+
# doing: a partial list cannot say where an UNNAMED folder went, so a "moved B
|
| 1133 |
+
# after C" message would leave every other folder's position to be re-derived by
|
| 1134 |
+
# two sides that can disagree. The client sends the list it is looking at.
|
| 1135 |
+
#
|
| 1136 |
+
# ⚠ IDS THIS USER DOES NOT OWN ARE IGNORED, NOT REJECTED. A stale tab can emit an
|
| 1137 |
+
# order containing a folder that has since been deleted; dropping the whole event
|
| 1138 |
+
# would make the rail un-reorderable until reload, while dropping the unknown id
|
| 1139 |
+
# reorders exactly what still exists. Anything the payload omits keeps its
|
| 1140 |
+
# relative position AFTER the named ones — an order that silently deleted a
|
| 1141 |
+
# folder it merely failed to mention would be a data loss dressed as a sort.
|
| 1142 |
+
order = [str(i).strip()[:80] for i in (event.get('order') or [])]
|
| 1143 |
+
if not order:
|
| 1144 |
+
return None
|
| 1145 |
+
known = {f['id']: f for f in lst}
|
| 1146 |
+
seen, ranked = set(), []
|
| 1147 |
+
for fid_ in order:
|
| 1148 |
+
if fid_ in known and fid_ not in seen:
|
| 1149 |
+
seen.add(fid_)
|
| 1150 |
+
ranked.append(known[fid_])
|
| 1151 |
+
ranked.extend(f for f in lst if f['id'] not in seen)
|
| 1152 |
+
if len(ranked) != len(lst): # cannot happen; refuse rather than truncate
|
| 1153 |
+
return None
|
| 1154 |
+
for i, f in enumerate(ranked):
|
| 1155 |
+
f['order'] = i
|
| 1156 |
+
lst = ranked
|
| 1157 |
elif kind == 'folder_duplicate':
|
| 1158 |
new_id = str(event.get('newId') or '').strip()[:80]
|
| 1159 |
src = next((f for f in lst if f['id'] == fid), None)
|
|
|
|
| 1224 |
# invisible to the next render (the values leave WITH the column).
|
| 1225 |
return False
|
| 1226 |
|
| 1227 |
+
if kind == 'choice_rename':
|
| 1228 |
+
# ⭐ WAVE 20 (owner item 15, contract C-RENAME) — RENAMING AN OPTION CARRIES ITS VALUES.
|
| 1229 |
+
#
|
| 1230 |
+
# Owner: *"if I change the option from 'Aaron' to 'Aron' the existing field that has the
|
| 1231 |
+
# Selected 'Aaron' doesn't change to 'Aron'."* Correct, and it is a data-model fact rather
|
| 1232 |
+
# than an oversight: a select cell stores the option's LABEL, so renaming the option in
|
| 1233 |
+
# the field definition orphans every cell holding the old string. They do not render as
|
| 1234 |
+
# the new name; they render as a value that is no longer an option.
|
| 1235 |
+
#
|
| 1236 |
+
# ⛔ THE WIRE CARRIES AN EXPLICIT {from, to} MAPPING, NEVER A DIFF OF THE CHOICE LISTS.
|
| 1237 |
+
# Diffing is what makes this unsafe: rename A→B while also DELETING C and adding D, and a
|
| 1238 |
+
# differ sees two removals and two additions with no way to know which pairs up. It would
|
| 1239 |
+
# cheerfully rewrite every C cell to D. The client knows which row of its editor the user
|
| 1240 |
+
# typed in; that intent travels, and nothing here has to guess it.
|
| 1241 |
+
#
|
| 1242 |
+
# THREE PLACES HOLD THE OLD STRING, and missing any one is its own visible bug:
|
| 1243 |
+
# 1. the CELLS (`overlays`) — the owner's complaint;
|
| 1244 |
+
# 2. the FIELD's own `choices` list — else the picker still offers the old name;
|
| 1245 |
+
# 3. every VIEW that FILTERS or COLOURS BY that value — a saved view filtering
|
| 1246 |
+
# `Agent is "Aaron"` silently matches NOTHING the moment the value moves, which is
|
| 1247 |
+
# the failure that looks like data loss ([[aios-shared-views]]).
|
| 1248 |
+
key = str(event.get('key') or '')[:80]
|
| 1249 |
+
pairs = []
|
| 1250 |
+
for r in (event.get('renames') or ()):
|
| 1251 |
+
if not isinstance(r, dict):
|
| 1252 |
+
continue
|
| 1253 |
+
a, b = str(r.get('from') or '')[:200], str(r.get('to') or '')[:200]
|
| 1254 |
+
if a and b and a != b:
|
| 1255 |
+
pairs.append((a, b))
|
| 1256 |
+
field = field_by_key.get(key)
|
| 1257 |
+
if not key or not pairs or not isinstance(field, dict):
|
| 1258 |
+
return False
|
| 1259 |
+
if str(field.get('type') or '') not in ('select', 'multiselect', 'status'):
|
| 1260 |
+
return False
|
| 1261 |
+
# Only a field this caller may edit. A rename is a write to the field CONTRACT, so it
|
| 1262 |
+
# rides the same wall `field_upsert` does rather than a looser one of its own.
|
| 1263 |
+
if key in (ctx.hidden_keys or frozenset()):
|
| 1264 |
+
return False
|
| 1265 |
+
mapping = dict(pairs)
|
| 1266 |
+
|
| 1267 |
+
def _rename_in(ws_):
|
| 1268 |
+
fields_ = ws_.setdefault('fields', {})
|
| 1269 |
+
fld = fields_.get(key)
|
| 1270 |
+
if isinstance(fld, dict) and isinstance(fld.get('choices'), list):
|
| 1271 |
+
seen, out = set(), []
|
| 1272 |
+
for c in fld['choices']:
|
| 1273 |
+
if isinstance(c, dict):
|
| 1274 |
+
nm = mapping.get(str(c.get('name') or ''), None)
|
| 1275 |
+
if nm is not None:
|
| 1276 |
+
c = {**c, 'name': nm}
|
| 1277 |
+
ident = str(c.get('name') or '')
|
| 1278 |
+
else:
|
| 1279 |
+
c = mapping.get(str(c), str(c))
|
| 1280 |
+
ident = str(c)
|
| 1281 |
+
# A rename ONTO an existing option MERGES rather than duplicating it: the
|
| 1282 |
+
# user typed a name that already exists, and two identical options is a
|
| 1283 |
+
# picker bug. Their cells merge too, which is what the value rewrite below
|
| 1284 |
+
# does anyway.
|
| 1285 |
+
if ident in seen:
|
| 1286 |
+
continue
|
| 1287 |
+
seen.add(ident)
|
| 1288 |
+
out.append(c)
|
| 1289 |
+
fld['choices'] = out
|
| 1290 |
+
fields_[key] = fld
|
| 1291 |
+
for row in (ws_.get('overlays') or {}).values():
|
| 1292 |
+
if not isinstance(row, dict) or key not in row:
|
| 1293 |
+
continue
|
| 1294 |
+
val = row[key]
|
| 1295 |
+
if isinstance(val, list): # multiselect
|
| 1296 |
+
merged, out2 = set(), []
|
| 1297 |
+
for v in val:
|
| 1298 |
+
nv = mapping.get(str(v), str(v))
|
| 1299 |
+
if nv not in merged:
|
| 1300 |
+
merged.add(nv)
|
| 1301 |
+
out2.append(nv)
|
| 1302 |
+
row[key] = out2
|
| 1303 |
+
elif isinstance(val, str) and val in mapping:
|
| 1304 |
+
row[key] = mapping[val]
|
| 1305 |
+
# Views: filter rule values + colour-by keys naming the old option.
|
| 1306 |
+
for view in (ws_.get('views') or {}).values():
|
| 1307 |
+
if not isinstance(view, dict):
|
| 1308 |
+
continue
|
| 1309 |
+
cfg = view.get('config') if isinstance(view.get('config'), dict) else view
|
| 1310 |
+
|
| 1311 |
+
def _walk(nodes):
|
| 1312 |
+
for n in nodes or ():
|
| 1313 |
+
if not isinstance(n, dict):
|
| 1314 |
+
continue
|
| 1315 |
+
if isinstance(n.get('children'), list):
|
| 1316 |
+
_walk(n['children'])
|
| 1317 |
+
continue
|
| 1318 |
+
if str(n.get('colId') or '') != key:
|
| 1319 |
+
continue
|
| 1320 |
+
v = n.get('value')
|
| 1321 |
+
if isinstance(v, str) and v in mapping:
|
| 1322 |
+
n['value'] = mapping[v]
|
| 1323 |
+
elif isinstance(v, list):
|
| 1324 |
+
n['value'] = [mapping.get(str(x), x) for x in v]
|
| 1325 |
+
|
| 1326 |
+
_walk(cfg.get('filters') or [])
|
| 1327 |
+
# ⚠ `colorBy` NEEDS NO REWRITE, and the reason is worth stating because the
|
| 1328 |
+
# opposite looks obvious: it stores a COLUMN KEY (a string, validated against
|
| 1329 |
+
# `valid_keys` at `view_upsert`), not a {value: colour} map. Per-value colours
|
| 1330 |
+
# ride the CHOICE OBJECTS themselves, which this function renames in place —
|
| 1331 |
+
# `{**c, 'name': nm}` keeps every other key on the choice, colour included — so
|
| 1332 |
+
# a renamed option keeps its swatch for free. An earlier version of this handler
|
| 1333 |
+
# tried to rewrite `colorBy['rules']` and crashed the whole event pipeline with
|
| 1334 |
+
# `TypeError: unhashable type: 'dict'` on the FIRST view it touched.
|
| 1335 |
+
return ws_
|
| 1336 |
+
|
| 1337 |
+
if store.available():
|
| 1338 |
+
_tops(ctx).rename_choice_values(uname, _rename_in)
|
| 1339 |
+
else:
|
| 1340 |
+
_session_ready()
|
| 1341 |
+
_rename_in(ws)
|
| 1342 |
+
# True = the host must re-read: the client dropped its optimistic copy of the CHOICES,
|
| 1343 |
+
# but the rewritten cells and view filters only exist server-side until the next payload.
|
| 1344 |
+
return True
|
| 1345 |
+
|
| 1346 |
if kind == 'field_duplicate':
|
| 1347 |
# Wave-5 item 1. The DESTINATION key is client-generated (the established pattern —
|
| 1348 |
# the client must know it synchronously to place the clone right of the source), and
|
platform/core/releases.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/releases.py — what each deployed environment is RUNNING (wave 20, owner item 12 / R6).
|
| 2 |
+
|
| 3 |
+
⛔ **THIS MODULE EXISTS BECAUSE OF `ops/verify_portability.py` B2**, and the reason is worth
|
| 4 |
+
stating rather than inferring: the API process must stay HOST-AGNOSTIC. The releases panel was
|
| 5 |
+
first written with `HfApi`/`hf_hub_download` inside `routes_platform_admin.py`, and the gate
|
| 6 |
+
turned red on the first run — correctly. A hub client in a request handler couples the runtime to
|
| 7 |
+
HuggingFace, in the one process whose design promise is that moving hosts is a config change.
|
| 8 |
+
|
| 9 |
+
So the route DELEGATES here, exactly as `routes_assets` delegates to `core/assets.py` (the same
|
| 10 |
+
rule, the same shape, and that module's own note says so). The SDK import is LAZY and every path
|
| 11 |
+
is gated on a token being configured, so a deployment with no hub credential simply reports
|
| 12 |
+
nothing rather than failing to import.
|
| 13 |
+
|
| 14 |
+
⚠ **WHAT THIS READS IS EACH SPACE'S OWN CLAIM ABOUT ITSELF.** `VERSION` is written INTO the Space
|
| 15 |
+
by `deploy_web.py` at deploy time, so "what is LIVE running" is a lookup, not an inference from
|
| 16 |
+
timestamps — `last_modified` moves when a SECRET is pushed, which would report a deploy that never
|
| 17 |
+
happened. An unreachable environment yields a NOTE, never a blank: about a production environment,
|
| 18 |
+
"no version" reads as "nothing is deployed", which is the worst available way to be wrong.
|
| 19 |
+
"""
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _token():
|
| 25 |
+
"""The credential that can read the Spaces. `AIOS_HF_TOKEN` is the personal (fsanyoto) token
|
| 26 |
+
that owns the staging Space; `HF_TOKEN` is the org-scoped one. Either can read."""
|
| 27 |
+
return os.environ.get('AIOS_HF_TOKEN') or os.environ.get('HF_TOKEN') or None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
#: The two environments, by Space id. ⚠ These are REPO IDENTIFIERS, not host literals — no URL is
|
| 31 |
+
#: built anywhere in this module (portability C1). Mirrors `deploy_web.STAGING`/`LIVE`, which
|
| 32 |
+
#: cannot be imported here: it lives outside the container's tree.
|
| 33 |
+
ENVIRONMENTS = (('staging', 'fsanyoto/loopable'), ('live', 'royal-imports/cfo-os'))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def environments():
|
| 37 |
+
"""[{env, space, version, note, stage}] — one row per deployed environment.
|
| 38 |
+
|
| 39 |
+
Never raises: every failure becomes a `note` on its own row. A panel that 500s because one
|
| 40 |
+
Space was briefly unreachable tells the operator less than a row saying so.
|
| 41 |
+
"""
|
| 42 |
+
tok = _token()
|
| 43 |
+
out = []
|
| 44 |
+
for name, repo in ENVIRONMENTS:
|
| 45 |
+
row = {'env': name, 'space': repo, 'version': None, 'note': None, 'stage': None}
|
| 46 |
+
if not tok:
|
| 47 |
+
row['note'] = ('no hub credential is configured on this deployment, so other '
|
| 48 |
+
'environments cannot be read from here')
|
| 49 |
+
out.append(row)
|
| 50 |
+
continue
|
| 51 |
+
try:
|
| 52 |
+
from huggingface_hub import hf_hub_download # noqa: PLC0415 — lazy, see header
|
| 53 |
+
p = hf_hub_download(repo, 'VERSION', repo_type='space', token=tok)
|
| 54 |
+
with open(p, encoding='utf-8') as f:
|
| 55 |
+
row['version'] = f.read().strip().splitlines()[0]
|
| 56 |
+
except Exception as e: # noqa: BLE001
|
| 57 |
+
row['note'] = f'could not read this Space\'s VERSION ({type(e).__name__})'
|
| 58 |
+
try:
|
| 59 |
+
from huggingface_hub import HfApi # noqa: PLC0415
|
| 60 |
+
row['stage'] = str(HfApi(token=tok).space_info(repo).runtime.stage)
|
| 61 |
+
except Exception: # noqa: BLE001
|
| 62 |
+
pass
|
| 63 |
+
out.append(row)
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def history():
|
| 68 |
+
"""Every `vN` ever cut: [{version, sha, date, subject}], newest first.
|
| 69 |
+
|
| 70 |
+
Read from `RELEASES.json`, which `deploy_web.py` ships INTO the Space beside `VERSION` — the
|
| 71 |
+
history has to travel with the build because the container has no git checkout to derive it
|
| 72 |
+
from. Tries the local filesystem FIRST (this deployment's own copy, no network at all) and
|
| 73 |
+
only then asks the hub, so the common case costs nothing.
|
| 74 |
+
"""
|
| 75 |
+
from pathlib import Path
|
| 76 |
+
|
| 77 |
+
for p in (Path('RELEASES.json'), Path(__file__).resolve().parents[2] / 'RELEASES.json'):
|
| 78 |
+
try:
|
| 79 |
+
if p.is_file():
|
| 80 |
+
return (json.loads(p.read_text(encoding='utf-8')) or {}).get('releases') or []
|
| 81 |
+
except Exception: # noqa: BLE001
|
| 82 |
+
pass
|
| 83 |
+
tok = _token()
|
| 84 |
+
if not tok:
|
| 85 |
+
return []
|
| 86 |
+
for _name, repo in ENVIRONMENTS:
|
| 87 |
+
try:
|
| 88 |
+
from huggingface_hub import hf_hub_download # noqa: PLC0415
|
| 89 |
+
q = hf_hub_download(repo, 'RELEASES.json', repo_type='space', token=tok)
|
| 90 |
+
with open(q, encoding='utf-8') as f:
|
| 91 |
+
return (json.load(f) or {}).get('releases') or []
|
| 92 |
+
except Exception: # noqa: BLE001
|
| 93 |
+
continue
|
| 94 |
+
return []
|
platform/core/shares.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/shares.py — ONE grant registry for every shareable object (wave 20, owner ruling R10).
|
| 2 |
+
|
| 3 |
+
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
|
| 4 |
+
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
|
| 5 |
+
that can add or revoke people later, on any of the three.
|
| 6 |
+
|
| 7 |
+
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
|
| 8 |
+
(`core/table_store.py`) and that stays — moving it would rewrite every stored view for no gain.
|
| 9 |
+
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
|
| 10 |
+
definition; giving each its own grant field would put the same three-line permission decision in
|
| 11 |
+
three files owned by two sessions, which is how the three drift. One registry, one predicate,
|
| 12 |
+
three callers.
|
| 13 |
+
|
| 14 |
+
shares.set_grants(kind, oid, entries, owner=…, st=…) # replaces the whole grant set
|
| 15 |
+
shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
|
| 16 |
+
shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
|
| 17 |
+
shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
|
| 18 |
+
|
| 19 |
+
THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
|
| 20 |
+
`edit` = may also change the object's CONTENT. Neither ever means "may re-share": changing grants
|
| 21 |
+
is the OWNER's (or an admin's), which is `table_store._may_administer`'s existing rule promoted to
|
| 22 |
+
every kind. A collaborator who could rewrite grants could grant themselves sole ownership of
|
| 23 |
+
somebody else's object, or quietly widen a users-scoped share to everyone.
|
| 24 |
+
|
| 25 |
+
⛔ AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed — junk in the bucket, a missing
|
| 26 |
+
owner, an unknown role string all resolve to None rather than to a default that opens something.
|
| 27 |
+
[[aios-permissioning]]: no fail-open defaults, ever.
|
| 28 |
+
|
| 29 |
+
⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
|
| 30 |
+
session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
|
| 31 |
+
(`core.store`) is tenant #0 and exists for the same reason it does everywhere else — the ~28
|
| 32 |
+
callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
|
| 33 |
+
NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
|
| 34 |
+
"""
|
| 35 |
+
import core.store as store
|
| 36 |
+
|
| 37 |
+
#: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
|
| 38 |
+
#: is a question across kinds — the "Shared with me" folder (R10) is exactly that query, and
|
| 39 |
+
#: three separate buckets would make it three reads that can disagree about what a user can see.
|
| 40 |
+
SHARES_KEY = 'object_shares'
|
| 41 |
+
|
| 42 |
+
#: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
|
| 43 |
+
#: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
|
| 44 |
+
KINDS = ('view', 'folder', 'database')
|
| 45 |
+
|
| 46 |
+
#: `*` is "everyone who can already open the surface". It is NOT "every account on the platform" —
|
| 47 |
+
#: the module/table wall runs FIRST and this never widens past it. Spelled as a single character
|
| 48 |
+
#: so it can never collide with a username (usernames are lower-case and non-empty by
|
| 49 |
+
#: `core/users.py`, and are checked against this explicitly below).
|
| 50 |
+
EVERYONE = '*'
|
| 51 |
+
|
| 52 |
+
ROLES = ('view', 'edit')
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _st(st):
|
| 56 |
+
return st if st is not None else store
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _check_kind(kind):
|
| 60 |
+
k = str(kind or '').strip().lower()
|
| 61 |
+
if k not in KINDS:
|
| 62 |
+
raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)} — '
|
| 63 |
+
f'refusing to invent a namespace from a typo.')
|
| 64 |
+
return k
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _clean_entries(entries):
|
| 68 |
+
"""Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
|
| 69 |
+
|
| 70 |
+
Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
|
| 71 |
+
that just listed the people it is about to grant, so a rejected row must not abort the whole
|
| 72 |
+
save — but an entry with an unknown ROLE must not be stored as something else's default
|
| 73 |
+
either. Dropped, never coerced.
|
| 74 |
+
"""
|
| 75 |
+
out, seen = [], set()
|
| 76 |
+
for e in entries or ():
|
| 77 |
+
if not isinstance(e, dict):
|
| 78 |
+
continue
|
| 79 |
+
user = str(e.get('user') or '').strip().lower()
|
| 80 |
+
role = str(e.get('role') or '').strip().lower()
|
| 81 |
+
if not user or role not in ROLES or user in seen:
|
| 82 |
+
continue
|
| 83 |
+
seen.add(user)
|
| 84 |
+
out.append({'user': user, 'role': role})
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def grants(kind, oid, st=None):
|
| 89 |
+
"""`{'owner': str|None, 'entries': [{'user','role'}]}` — never raises on a junk bucket."""
|
| 90 |
+
kind = _check_kind(kind)
|
| 91 |
+
try:
|
| 92 |
+
bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
|
| 93 |
+
rec = bucket.get(str(oid)) or {}
|
| 94 |
+
except Exception:
|
| 95 |
+
return {'owner': None, 'entries': []}
|
| 96 |
+
if not isinstance(rec, dict):
|
| 97 |
+
return {'owner': None, 'entries': []}
|
| 98 |
+
return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
|
| 99 |
+
'entries': _clean_entries(rec.get('entries'))}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def set_grants(kind, oid, entries, owner=None, st=None):
|
| 103 |
+
"""REPLACE the grant set for one object. Returns the stored record.
|
| 104 |
+
|
| 105 |
+
⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
|
| 106 |
+
entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
|
| 107 |
+
manage-access editor R10 asks for is exactly "here is the list now".
|
| 108 |
+
"""
|
| 109 |
+
kind = _check_kind(kind)
|
| 110 |
+
oid = str(oid)
|
| 111 |
+
clean = _clean_entries(entries)
|
| 112 |
+
owner_l = str(owner).strip().lower() if owner else None
|
| 113 |
+
|
| 114 |
+
def _apply(data):
|
| 115 |
+
by_kind = dict(data.get(kind) or {})
|
| 116 |
+
prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
|
| 117 |
+
# The owner is STICKY: set once, and a later save that omits it must not orphan the
|
| 118 |
+
# object. An ownerless grant record cannot answer "who may re-share this", so every
|
| 119 |
+
# administer check would fail closed and the object would become unmanageable.
|
| 120 |
+
keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
|
| 121 |
+
if prior.get('owner') else None)
|
| 122 |
+
if not clean and not keep_owner:
|
| 123 |
+
by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
|
| 124 |
+
else:
|
| 125 |
+
by_kind[oid] = {'owner': keep_owner, 'entries': clean}
|
| 126 |
+
data[kind] = by_kind
|
| 127 |
+
return data
|
| 128 |
+
|
| 129 |
+
_st(st).update(SHARES_KEY, _apply, flush='async')
|
| 130 |
+
return grants(kind, oid, st=st)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def role_for(kind, oid, user, is_admin=False, st=None):
|
| 134 |
+
"""`'owner'` | `'edit'` | `'view'` | `None` — the caller's effective role, fail-closed.
|
| 135 |
+
|
| 136 |
+
An ADMIN reads as `'owner'`: an admin who could not administer an object could not
|
| 137 |
+
administer the tenant either, which is `table_store._may_administer`'s existing rule and is
|
| 138 |
+
kept identical here so the two cannot disagree about the same view.
|
| 139 |
+
"""
|
| 140 |
+
user = str(user or '').strip().lower()
|
| 141 |
+
if not user:
|
| 142 |
+
return None
|
| 143 |
+
rec = grants(kind, oid, st=st)
|
| 144 |
+
if is_admin or (rec['owner'] and rec['owner'] == user):
|
| 145 |
+
return 'owner'
|
| 146 |
+
best = None
|
| 147 |
+
for e in rec['entries']:
|
| 148 |
+
if e['user'] == user or e['user'] == EVERYONE:
|
| 149 |
+
# The STRONGER of the two wins when both a personal and an everyone grant exist:
|
| 150 |
+
# naming somebody explicitly is how you RAISE them above the room, so an
|
| 151 |
+
# everyone-view + alice-edit pair must leave alice editing.
|
| 152 |
+
if e['role'] == 'edit':
|
| 153 |
+
return 'edit'
|
| 154 |
+
best = best or 'view'
|
| 155 |
+
return best
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def may_see(kind, oid, user, is_admin=False, st=None):
|
| 159 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def may_edit(kind, oid, user, is_admin=False, st=None):
|
| 163 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def may_administer(kind, oid, user, is_admin=False, st=None):
|
| 167 |
+
"""Only the owner or an admin may change grants or delete. See the module note on why this
|
| 168 |
+
is deliberately narrower than `may_edit`."""
|
| 169 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) == 'owner'
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def shared_with(user, kind=None, st=None):
|
| 173 |
+
"""Every object id this user has been granted (excluding what they own).
|
| 174 |
+
|
| 175 |
+
This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
|
| 176 |
+
you made is not something shared *with* you, and listing it there would make the system
|
| 177 |
+
folder a duplicate of the rail above it.
|
| 178 |
+
"""
|
| 179 |
+
user = str(user or '').strip().lower()
|
| 180 |
+
if not user:
|
| 181 |
+
return {}
|
| 182 |
+
try:
|
| 183 |
+
data = _st(st).get(SHARES_KEY) or {}
|
| 184 |
+
except Exception:
|
| 185 |
+
return {}
|
| 186 |
+
out = {}
|
| 187 |
+
for k in ([_check_kind(kind)] if kind else KINDS):
|
| 188 |
+
hits = []
|
| 189 |
+
for oid, rec in (data.get(k) or {}).items():
|
| 190 |
+
if not isinstance(rec, dict):
|
| 191 |
+
continue
|
| 192 |
+
owner = str(rec.get('owner') or '').strip().lower()
|
| 193 |
+
if owner == user:
|
| 194 |
+
continue
|
| 195 |
+
for e in _clean_entries(rec.get('entries')):
|
| 196 |
+
if e['user'] in (user, EVERYONE):
|
| 197 |
+
hits.append(str(oid))
|
| 198 |
+
break
|
| 199 |
+
out[k] = sorted(hits)
|
| 200 |
+
return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
|
platform/core/store.py
CHANGED
|
@@ -304,8 +304,15 @@ _INSTANCES_LOCK = threading.RLock()
|
|
| 304 |
|
| 305 |
|
| 306 |
def for_repo(repo_id):
|
| 307 |
-
"""The Store bound to `repo_id` — one instance per repo per process (each with its own
|
| 308 |
-
cache and flush state).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
rid = str(repo_id or '').strip() or REPO
|
| 310 |
with _INSTANCES_LOCK:
|
| 311 |
inst = _INSTANCES.get(rid)
|
|
@@ -315,47 +322,119 @@ def for_repo(repo_id):
|
|
| 315 |
return inst
|
| 316 |
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
_DEFAULT = for_repo(REPO)
|
| 319 |
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
def available():
|
| 322 |
-
return
|
| 323 |
|
| 324 |
|
| 325 |
def get(name, fresh=False):
|
| 326 |
-
return
|
| 327 |
|
| 328 |
|
| 329 |
def _read_strict(name):
|
| 330 |
-
return
|
| 331 |
|
| 332 |
|
| 333 |
def exists(name):
|
| 334 |
-
return
|
| 335 |
|
| 336 |
|
| 337 |
def upload_bytes(path_in_repo, data, message=None):
|
| 338 |
-
return
|
| 339 |
|
| 340 |
|
| 341 |
def download_bytes(path_in_repo):
|
| 342 |
-
return
|
| 343 |
|
| 344 |
|
| 345 |
def delete_path(path_in_repo):
|
| 346 |
-
return
|
| 347 |
|
| 348 |
|
| 349 |
def put(name, data):
|
| 350 |
-
return
|
| 351 |
|
| 352 |
|
| 353 |
def flush(name=None, timeout=30.0):
|
| 354 |
-
return
|
| 355 |
|
| 356 |
|
| 357 |
def update(name, fn, flush='sync'):
|
| 358 |
-
return
|
| 359 |
|
| 360 |
|
| 361 |
def _flush_all_at_exit():
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
def for_repo(repo_id):
|
| 307 |
+
"""The HF Store bound to `repo_id` — one instance per repo per process (each with its own
|
| 308 |
+
cache and flush state).
|
| 309 |
+
|
| 310 |
+
⚠ WAVE 20: this is now the HF-SPECIFIC factory. Callers that want "the right store for this
|
| 311 |
+
tenant, whatever the backend is" call `handle()` below. `for_repo` keeps its exact old
|
| 312 |
+
behaviour because the seed/migration tooling has to be able to name the FILE store
|
| 313 |
+
explicitly while the app runs on Postgres — a migration that could only reach the active
|
| 314 |
+
backend could not copy between them.
|
| 315 |
+
"""
|
| 316 |
rid = str(repo_id or '').strip() or REPO
|
| 317 |
with _INSTANCES_LOCK:
|
| 318 |
inst = _INSTANCES.get(rid)
|
|
|
|
| 322 |
return inst
|
| 323 |
|
| 324 |
|
| 325 |
+
# =============================================================================================
|
| 326 |
+
# WAVE 20 (owner ruling R1, closes DEBT D-4) — THE BACKEND SEAM.
|
| 327 |
+
#
|
| 328 |
+
# `core/store_backend.py` has carried this warning since EXIT-2b, and it was correct at the time:
|
| 329 |
+
#
|
| 330 |
+
# "Flipping STORE_BACKEND=pg does NOT redirect the ~40 existing callers that say
|
| 331 |
+
# `import core.store as store` — they are bound to the HF module directly. Rewiring them is
|
| 332 |
+
# task C-4 … THE DEFAULT IS `hf` AND STAYS `hf` until C-4 says otherwise."
|
| 333 |
+
#
|
| 334 |
+
# **R1 IS C-4.** The owner ruled the cutover on 2026-08-05 (the D-4 trigger that fired: a SECOND
|
| 335 |
+
# server process — `royal-imports/cfo-os` came back as a pinned LIVE environment beside staging,
|
| 336 |
+
# and two containers on one last-write-wins file store is the race B-3 was always about).
|
| 337 |
+
#
|
| 338 |
+
# THE REWIRE IS HERE RATHER THAN IN 28 FILES, and that is a deliberate choice over the obvious
|
| 339 |
+
# alternative of `sed`-ing every `import core.store as store` to `import core.store_backend`:
|
| 340 |
+
# * every one of those callers means "the store for the tenant I am serving", which is exactly
|
| 341 |
+
# what this module has always meant. The BACKEND is not their concern and making it their
|
| 342 |
+
# concern is how one of them gets missed;
|
| 343 |
+
# * a missed caller under a search-and-replace does not fail — it silently keeps writing to the
|
| 344 |
+
# file store while everything else writes to Postgres. That is the split-brain this whole
|
| 345 |
+
# ruling exists to end, reintroduced by the fix for it;
|
| 346 |
+
# * `store_backend.py`'s stated reason for refusing to do it this way — "no dual-read window and
|
| 347 |
+
# no way to compare the two stores' contents first" — is satisfied: `ops/seed_pg_from_hf.py`
|
| 348 |
+
# copies, then `--verify` diffs the two stores key-by-key before anything flips.
|
| 349 |
+
#
|
| 350 |
+
# ⛔ THE IMPORT IS LAZY AND MUST STAY LAZY. `core/store_pg.py` imports psycopg only inside
|
| 351 |
+
# `_pool()`, so an `hf` deployment installs no driver; importing it at module scope here would
|
| 352 |
+
# undo that and make the default backend depend on the optional dependency.
|
| 353 |
+
# =============================================================================================
|
| 354 |
+
|
| 355 |
+
def backend():
|
| 356 |
+
"""`'hf'` | `'pg'` — validated, resolved per call so a test can flip the env var.
|
| 357 |
+
|
| 358 |
+
Mirrors `core.store_backend.name()` deliberately rather than importing it: that module
|
| 359 |
+
imports THIS one, so reaching back would be a cycle. `verify_store_pg` asserts the two agree
|
| 360 |
+
on every value, which is the guard against them drifting apart.
|
| 361 |
+
"""
|
| 362 |
+
raw = (os.environ.get('STORE_BACKEND') or 'hf').strip().lower()
|
| 363 |
+
if raw not in ('hf', 'pg'):
|
| 364 |
+
raise RuntimeError(
|
| 365 |
+
f"STORE_BACKEND={raw!r} is not a backend. Use 'hf' (the HF Dataset store) or 'pg' "
|
| 366 |
+
f"(Postgres, needs DATABASE_URL). Refusing to guess: a typo that silently served the "
|
| 367 |
+
f"other store is how data ends up in two places.")
|
| 368 |
+
return raw
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def handle(repo_id=None, slug=None):
|
| 372 |
+
"""THE tenant-bound store handle for the ACTIVE backend — the one seam every caller crosses.
|
| 373 |
+
|
| 374 |
+
`repo_id` addresses the HF backend (a dataset repo); `slug` addresses Postgres (a schema).
|
| 375 |
+
Both are passed by `harness.runtime.get_runtime`, which knows both facts, so flipping the
|
| 376 |
+
backend never needs a lookup table between them — and neither identifier has to be invented
|
| 377 |
+
for the backend that does not use it.
|
| 378 |
+
"""
|
| 379 |
+
if backend() == 'pg':
|
| 380 |
+
import core.store_pg as _pg # noqa: PLC0415 — lazy: see the banner above
|
| 381 |
+
return _pg.PgStore(slug or os.environ.get('AIOS_TENANT') or 'royal-imports')
|
| 382 |
+
return for_repo(repo_id)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
#: The HF default instance. Kept as a module global (not a property) because the seed/migration
|
| 386 |
+
#: tooling and `_flush_all_at_exit` both need the FILE store by name even when pg is active.
|
| 387 |
_DEFAULT = for_repo(REPO)
|
| 388 |
|
| 389 |
|
| 390 |
+
def _d():
|
| 391 |
+
"""The default tenant's store on the active backend — what every module function below uses.
|
| 392 |
+
|
| 393 |
+
⚠ Resolved PER CALL, never cached. A cached default would freeze the backend at import time,
|
| 394 |
+
and import order is exactly what nobody controls: `api/main.py` imports half the platform
|
| 395 |
+
before it has read a single environment variable it did not inherit.
|
| 396 |
+
"""
|
| 397 |
+
return handle() if backend() == 'pg' else _DEFAULT
|
| 398 |
+
|
| 399 |
+
|
| 400 |
def available():
|
| 401 |
+
return _d().available()
|
| 402 |
|
| 403 |
|
| 404 |
def get(name, fresh=False):
|
| 405 |
+
return _d().get(name, fresh=fresh)
|
| 406 |
|
| 407 |
|
| 408 |
def _read_strict(name):
|
| 409 |
+
return _d()._read_strict(name)
|
| 410 |
|
| 411 |
|
| 412 |
def exists(name):
|
| 413 |
+
return _d().exists(name)
|
| 414 |
|
| 415 |
|
| 416 |
def upload_bytes(path_in_repo, data, message=None):
|
| 417 |
+
return _d().upload_bytes(path_in_repo, data, message=message)
|
| 418 |
|
| 419 |
|
| 420 |
def download_bytes(path_in_repo):
|
| 421 |
+
return _d().download_bytes(path_in_repo)
|
| 422 |
|
| 423 |
|
| 424 |
def delete_path(path_in_repo):
|
| 425 |
+
return _d().delete_path(path_in_repo)
|
| 426 |
|
| 427 |
|
| 428 |
def put(name, data):
|
| 429 |
+
return _d().put(name, data)
|
| 430 |
|
| 431 |
|
| 432 |
def flush(name=None, timeout=30.0):
|
| 433 |
+
return _d().flush(name=name, timeout=timeout)
|
| 434 |
|
| 435 |
|
| 436 |
def update(name, fn, flush='sync'):
|
| 437 |
+
return _d().update(name, fn, flush=flush)
|
| 438 |
|
| 439 |
|
| 440 |
def _flush_all_at_exit():
|
platform/core/store_backend.py
CHANGED
|
@@ -3,21 +3,30 @@
|
|
| 3 |
import core.store_backend as store # new code
|
| 4 |
store.get('users') # goes to whichever backend is selected
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
`import core.store as store`
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
import os
|
| 23 |
|
|
|
|
| 3 |
import core.store_backend as store # new code
|
| 4 |
store.get('users') # goes to whichever backend is selected
|
| 5 |
|
| 6 |
+
⭐ **C-4 HAPPENED — WAVE 20, 2026-08-05 (owner ruling R1, DEBT D-4).** This header used to say,
|
| 7 |
+
in bold, that flipping `STORE_BACKEND=pg` did NOT redirect the ~28 callers that say
|
| 8 |
+
`import core.store as store`, and that "the tempting shortcut — have `core/store.py` itself
|
| 9 |
+
delegate" would be worse because there was "no dual-read window and no way to compare the two
|
| 10 |
+
stores' contents first".
|
| 11 |
+
|
| 12 |
+
Both halves of that objection were ANSWERED rather than ignored, which is the only reason the
|
| 13 |
+
shortcut became the design:
|
| 14 |
+
* the comparison exists — `ops/seed_pg_from_hf.py --verify` diffs the two stores key-by-key
|
| 15 |
+
(and value-by-value) and is run BEFORE any environment flips;
|
| 16 |
+
* the "silently changes backend the moment an env var is set in some shell" risk is why the
|
| 17 |
+
flip is fail-closed and loud: no `DATABASE_URL` under `pg` RAISES, it never falls back to the
|
| 18 |
+
file store. A misconfigured process refuses to serve instead of quietly writing to the wrong
|
| 19 |
+
place — which is the failure mode the original warning actually cared about.
|
| 20 |
+
|
| 21 |
+
So **`core/store.py::handle()` is now the seam**, and it is the one every caller crosses:
|
| 22 |
+
module-level functions resolve it per call via `_d()`, and `harness.runtime.get_runtime` binds a
|
| 23 |
+
per-TENANT handle (a dataset repo on `hf`, a `t_<slug>` schema on `pg`). THIS module remains the
|
| 24 |
+
by-name selector for code that wants a specific backend's module rather than the active handle —
|
| 25 |
+
the migration tooling, and `verify_store_pg`, which asserts `store.backend()` and `name()` agree
|
| 26 |
+
on every value so the two entry points cannot drift.
|
| 27 |
+
|
| 28 |
+
An unrecognised value is a configuration error and RAISES rather than falling back — a typo'd
|
| 29 |
+
backend name that quietly served the old store would be discovered by data going missing.
|
| 30 |
"""
|
| 31 |
import os
|
| 32 |
|
platform/core/store_pg.py
CHANGED
|
@@ -249,3 +249,77 @@ def close():
|
|
| 249 |
_POOL['pool'] = None
|
| 250 |
_POOL['url'] = None
|
| 251 |
_POOL.pop('ok', None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
_POOL['pool'] = None
|
| 250 |
_POOL['url'] = None
|
| 251 |
_POOL.pop('ok', None)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ---------------------------------------------------------------------------------------------
|
| 255 |
+
# WAVE 20 (R1 / D-4) — THE TENANT-BOUND HANDLE. This is what made the cutover a small change.
|
| 256 |
+
#
|
| 257 |
+
# `core/store.py` grew a CLASS in wave 18 because a tenant can own its own dataset REPO, and
|
| 258 |
+
# `harness.runtime.TenantRuntime` carries one bound instance per tenant. Postgres isolates by
|
| 259 |
+
# SCHEMA instead (`t_<slug>`, see harness/pg/schema.sql's own argument for schema-over-RLS), so
|
| 260 |
+
# the two models meet here: one object, bound to one slug, exposing `core.store.Store`'s methods.
|
| 261 |
+
#
|
| 262 |
+
# ⚠ THE BINDING IS THE POINT. The module functions above default their schema from `AIOS_TENANT`,
|
| 263 |
+
# which is a PROCESS-wide answer to a PER-REQUEST question — correct for a single-tenant worker,
|
| 264 |
+
# wrong for the shared API that serves four tenants from one process. A handle carries its slug
|
| 265 |
+
# explicitly, so a write cannot land in another tenant's schema because some env var was right
|
| 266 |
+
# for the last request. That is the same property `TenantRuntime.store_handle` already gives the
|
| 267 |
+
# HF backend (EXIT-4b proof #3), reached a different way.
|
| 268 |
+
# ---------------------------------------------------------------------------------------------
|
| 269 |
+
class PgStore:
|
| 270 |
+
"""`core.store.Store`'s interface for ONE tenant's schema.
|
| 271 |
+
|
| 272 |
+
Deliberately a thin binder over the module functions rather than a reimplementation: every
|
| 273 |
+
behaviour argument (FOR UPDATE in `update`, absent-is-success in `delete_path`, `fresh`
|
| 274 |
+
ignored because a SELECT has no cache to bypass) is stated once, up there, and cannot drift
|
| 275 |
+
between the two entry points.
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
def __init__(self, tenant_slug):
|
| 279 |
+
self.tenant_slug = str(tenant_slug or '').strip().lower() or 'royal-imports'
|
| 280 |
+
#: Kept so `Store`-shaped debugging (`repr`, telemetry, the admin panes) reads the same
|
| 281 |
+
#: on both backends. There is no repo here; the schema is the address.
|
| 282 |
+
self.repo = f'pg:{_schema_for(self.tenant_slug)}'
|
| 283 |
+
|
| 284 |
+
def __repr__(self):
|
| 285 |
+
return f'<PgStore {self.repo}>'
|
| 286 |
+
|
| 287 |
+
def available(self):
|
| 288 |
+
return available()
|
| 289 |
+
|
| 290 |
+
def get(self, name, fresh=False):
|
| 291 |
+
return get(name, fresh=fresh, tenant_slug=self.tenant_slug)
|
| 292 |
+
|
| 293 |
+
def _read_strict(self, name):
|
| 294 |
+
"""Interface parity with the HF Store. There, `get` is lenient (swallows a transient
|
| 295 |
+
failure) and `_read_strict` opts out so a failed read ABORTS a read-modify-write instead
|
| 296 |
+
of merging into `{}`. Here `get` ALREADY raises — the leniency it opts out of does not
|
| 297 |
+
exist — so the strict read is the ordinary one, and saying so beats a second code path."""
|
| 298 |
+
return get(name, tenant_slug=self.tenant_slug)
|
| 299 |
+
|
| 300 |
+
def exists(self, name):
|
| 301 |
+
return exists(name, tenant_slug=self.tenant_slug)
|
| 302 |
+
|
| 303 |
+
def put(self, name, data):
|
| 304 |
+
return put(name, data, tenant_slug=self.tenant_slug)
|
| 305 |
+
|
| 306 |
+
def update(self, name, fn, flush='sync'):
|
| 307 |
+
return update(name, fn, flush=flush, tenant_slug=self.tenant_slug)
|
| 308 |
+
|
| 309 |
+
def upload_bytes(self, path_in_repo, data, message=None):
|
| 310 |
+
return upload_bytes(path_in_repo, data, message=message, tenant_slug=self.tenant_slug)
|
| 311 |
+
|
| 312 |
+
def download_bytes(self, path_in_repo):
|
| 313 |
+
return download_bytes(path_in_repo, tenant_slug=self.tenant_slug)
|
| 314 |
+
|
| 315 |
+
def delete_path(self, path_in_repo):
|
| 316 |
+
return delete_path(path_in_repo, tenant_slug=self.tenant_slug)
|
| 317 |
+
|
| 318 |
+
def flush(self, name=None, timeout=30.0):
|
| 319 |
+
return True
|
| 320 |
+
|
| 321 |
+
def _flush_now_at_exit(self):
|
| 322 |
+
"""The atexit hook `core.store` registers for its instances. Nothing is buffered here —
|
| 323 |
+
every write above committed before it returned — so this is honestly a no-op rather than
|
| 324 |
+
an unimplemented method that would raise during interpreter shutdown."""
|
| 325 |
+
return True
|
platform/core/table_store.py
CHANGED
|
@@ -199,6 +199,21 @@ class TableStore:
|
|
| 199 |
# coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
|
| 200 |
return self._st.update(self.table_key, _up, flush='async')
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
def save_active_view(self, username, view_id):
|
| 203 |
"""Remember which view this user last opened (owner item 3, 2026-07-31).
|
| 204 |
|
|
|
|
| 199 |
# coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
|
| 200 |
return self._st.update(self.table_key, _up, flush='async')
|
| 201 |
|
| 202 |
+
def rename_choice_values(self, username, change):
|
| 203 |
+
"""Apply an arbitrary workspace rewrite (wave 20, item 15 / C-RENAME).
|
| 204 |
+
|
| 205 |
+
⚠ NAMED FOR ITS ONE CALLER RATHER THAN EXPOSED AS A GENERIC `mutate`, deliberately. A
|
| 206 |
+
public "do anything to the workspace" method is an invitation to put write logic in
|
| 207 |
+
callers instead of here, and every OTHER method on this class exists precisely because
|
| 208 |
+
that logic belongs in one place. Renaming a choice is the one operation that must touch
|
| 209 |
+
three strata AT ONCE — the field's `choices`, the cells in `overlays`, and the views that
|
| 210 |
+
filter or colour by the old value — inside a SINGLE transaction, because a rename that
|
| 211 |
+
updated the cells and not the filters would leave a saved view matching nothing.
|
| 212 |
+
|
| 213 |
+
`change(ws)` receives the whole workspace with every stratum pre-created (see `_update`).
|
| 214 |
+
"""
|
| 215 |
+
return self._update(username, change)
|
| 216 |
+
|
| 217 |
def save_active_view(self, username, view_id):
|
| 218 |
"""Remember which view this user last opened (owner item 3, 2026-07-31).
|
| 219 |
|
platform/core/user_tables.py
CHANGED
|
@@ -178,15 +178,26 @@ def set_fields(table_key, fields, st=None):
|
|
| 178 |
return True
|
| 179 |
|
| 180 |
|
| 181 |
-
def add_row(table_key, values=None, username=None, st=None):
|
| 182 |
-
"""Append one row. Returns the new row id, or None if refused.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if not is_user_table(table_key, st):
|
| 184 |
return None # ⚠ never on a connector-backed table
|
| 185 |
defn = get(table_key, st) or {}
|
| 186 |
rows = defn.get('rows') or {}
|
| 187 |
if len(rows) >= MAX_ROWS:
|
| 188 |
return None
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
valid = {f['key'] for f in (defn.get('fields') or [])}
|
| 191 |
clean = {k: str(v) for k, v in (values or {}).items() if k in valid}
|
| 192 |
|
|
@@ -200,45 +211,244 @@ def add_row(table_key, values=None, username=None, st=None):
|
|
| 200 |
return rid
|
| 201 |
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
if not is_user_table(table_key, st):
|
| 209 |
return None
|
| 210 |
defn = get(table_key, st) or {}
|
| 211 |
-
|
| 212 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
return None
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
if
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
def _apply(cur):
|
| 222 |
t = cur.get(str(table_key))
|
| 223 |
if t is None:
|
| 224 |
return cur
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
return cur
|
| 243 |
|
| 244 |
_st(st).update(STORE_KEY, _apply, flush='sync')
|
|
@@ -259,8 +469,15 @@ def delete_row(table_key, row_id, st=None):
|
|
| 259 |
return True
|
| 260 |
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
def may_open(table_key, viewer, is_admin=False, st=None):
|
| 263 |
-
"""FAIL-CLOSED visibility: the creator, or an admin. Nothing else.
|
| 264 |
|
| 265 |
A user table cannot be gated by `allowed_modules` — a module grant written last month cannot
|
| 266 |
describe a table created this morning — so it needs its own rule, and the safe rule is the
|
|
@@ -268,6 +485,14 @@ def may_open(table_key, viewer, is_admin=False, st=None):
|
|
| 268 |
a BU-scoped sales agent) could open a table somebody else created just by knowing its key.
|
| 269 |
Sharing a user table with named colleagues is a follow-on, and it should reuse the shared-
|
| 270 |
VIEW vocabulary rather than inventing a second one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
"""
|
| 272 |
t = get(table_key, st)
|
| 273 |
if not t:
|
|
|
|
| 178 |
return True
|
| 179 |
|
| 180 |
|
| 181 |
+
def add_row(table_key, values=None, username=None, st=None, rid=None):
|
| 182 |
+
"""Append one row. Returns the new row id, or None if refused.
|
| 183 |
+
|
| 184 |
+
`rid` RESTORES A ROW UNDER ITS OLD ID (contract C-ADDROW / C-UNDO). Undo has to put a deleted
|
| 185 |
+
row back where it was: a restore under a fresh id would break every cohort, comment and view
|
| 186 |
+
filter that named the original, so the id is part of what is being undone. It is honoured
|
| 187 |
+
only when that id is FREE — an undo can never overwrite a row somebody has since created in
|
| 188 |
+
the gap, and it never invents a non-numeric id, because `scoped_pool` reads row ids as ints.
|
| 189 |
+
"""
|
| 190 |
if not is_user_table(table_key, st):
|
| 191 |
return None # ⚠ never on a connector-backed table
|
| 192 |
defn = get(table_key, st) or {}
|
| 193 |
rows = defn.get('rows') or {}
|
| 194 |
if len(rows) >= MAX_ROWS:
|
| 195 |
return None
|
| 196 |
+
want = str(rid or '').strip()
|
| 197 |
+
if want and want.isdigit() and want not in rows:
|
| 198 |
+
rid = want
|
| 199 |
+
else:
|
| 200 |
+
rid = str(max((int(r) for r in rows if str(r).isdigit()), default=0) + 1)
|
| 201 |
valid = {f['key'] for f in (defn.get('fields') or [])}
|
| 202 |
clean = {k: str(v) for k, v in (values or {}).items() if k in valid}
|
| 203 |
|
|
|
|
| 211 |
return rid
|
| 212 |
|
| 213 |
|
| 214 |
+
# ⛔ `upsert_rows` USED TO LIVE HERE, AND DELETING IT IS THE FIX (DEBT D-6, wave 20).
|
| 215 |
+
#
|
| 216 |
+
# There were TWO bulk-upsert implementations — this one and `automation_engine.upsert_rows` —
|
| 217 |
+
# with different counts (`{updated, inserted, orphans}` vs the engine's seven, including the
|
| 218 |
+
# `capped` count that makes a full table LOUD), a different signature (a dict keyed by value vs
|
| 219 |
+
# a list of rows) and a different cap story. D-6 called it a "silent drift risk"; the honest
|
| 220 |
+
# measurement is worse and simpler: **this one had zero callers, anywhere in the repo.** It was
|
| 221 |
+
# not drifting from the engine, it was a second answer nobody had ever asked.
|
| 222 |
+
#
|
| 223 |
+
# So there is now one implementation because there is one implementation — no parity gate to
|
| 224 |
+
# maintain, no second definition to keep in step. If a host-side bulk upsert is ever wanted, the
|
| 225 |
+
# engine's is the one that has been exercised (verify_automation section B) and it is PURE:
|
| 226 |
+
# `(existing, incoming, key_field, cap) -> (rows, counts)`, so it can be lifted here without its
|
| 227 |
+
# store half coming along. Do not re-derive a new one.
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def set_label(table_key, label, st=None):
|
| 231 |
+
"""Rename a table IN ITS DEFINITION. Returns the stored label, or None if refused.
|
| 232 |
+
|
| 233 |
+
⚠ WAVE 20, item 6a — THE RENAME USED TO WRITE ONLY `nav_meta`. That bucket is the nav's
|
| 234 |
+
display layer, so the rail showed the new name while everything reading the DEFINITION —
|
| 235 |
+
the automation editor's database picker above all — went on showing the old one. A rename
|
| 236 |
+
that only some surfaces can see is worse than no rename: the picker was not stale-looking,
|
| 237 |
+
it was confidently wrong, and a user choosing "Influencers" there could be choosing the
|
| 238 |
+
table they had renamed to something else months earlier.
|
| 239 |
+
"""
|
| 240 |
+
label = ' '.join(str(label or '').split())[:MAX_LABEL]
|
| 241 |
+
if not label or not is_user_table(table_key, st):
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
def _set(cur):
|
| 245 |
+
t = cur.get(str(table_key))
|
| 246 |
+
if t is not None:
|
| 247 |
+
t['label'] = label
|
| 248 |
+
return cur
|
| 249 |
+
|
| 250 |
+
_st(st).update(STORE_KEY, _set, flush='sync')
|
| 251 |
+
return label
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ---------------------------------------------------------------------------------------------
|
| 255 |
+
# THE SHARED FIELD SCHEMA (contract C-FIELD, owner ruling R2)
|
| 256 |
+
# ---------------------------------------------------------------------------------------------
|
| 257 |
+
# R2 reverses wave 17's "fields are per-user" law FOR THIS PATH: a `ut_*` table's fields are the
|
| 258 |
+
# TABLE'S SCHEMA, the way they are in Airtable — everyone with access to the database sees the
|
| 259 |
+
# same columns, and the creator or an admin edits them. Per-field `editRole` narrows or widens
|
| 260 |
+
# who may edit ONE column's definition without handing over the whole table.
|
| 261 |
+
#
|
| 262 |
+
# ⚠ `editRole` GOVERNS THE SCHEMA, NEVER THE VALUES. 'everyone' means anybody with access may
|
| 263 |
+
# rename this column or change its options; it does not decide who may type in its cells. Those
|
| 264 |
+
# are different questions and conflating them would let a column's own settings quietly become a
|
| 265 |
+
# data-permission system nobody wrote.
|
| 266 |
+
|
| 267 |
+
#: Who may edit ONE field's definition. Fail-closed default: admins (= creator or admin).
|
| 268 |
+
FIELD_EDIT_ROLES = ('admins', 'everyone')
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _clean_field(raw, previous=None):
|
| 272 |
+
"""One field dict → the stored shape, or None. The single validator for create AND patch, so
|
| 273 |
+
a column cannot be typed one way on the way in and another on the way back."""
|
| 274 |
+
prev = previous or {}
|
| 275 |
+
raw = raw if isinstance(raw, dict) else {}
|
| 276 |
+
label = ' '.join(str(raw.get('label') or prev.get('label') or '').split())[:80]
|
| 277 |
+
key = re.sub(r'[^a-z0-9_]+', '_',
|
| 278 |
+
str(raw.get('key') or prev.get('key') or _slug(label)).strip().lower())
|
| 279 |
+
key = key.strip('_')[:60]
|
| 280 |
+
ftype = str(raw.get('type') or prev.get('type') or 'text').strip().lower()
|
| 281 |
+
if not key or not label or ftype not in UT_FIELD_TYPES:
|
| 282 |
+
return None
|
| 283 |
+
out = {'key': key, 'label': label, 'type': ftype, 'source': 'overlay'}
|
| 284 |
+
opts_raw = raw.get('options') if 'options' in raw else prev.get('options')
|
| 285 |
+
if ftype in ('select', 'multiselect'):
|
| 286 |
+
opts = [' '.join(str(o).split())[:60] for o in (opts_raw or []) if str(o).strip()][:50]
|
| 287 |
+
if opts:
|
| 288 |
+
out['options'] = opts
|
| 289 |
+
role = str(raw.get('editRole') or prev.get('editRole') or 'admins').strip().lower()
|
| 290 |
+
out['editRole'] = role if role in FIELD_EDIT_ROLES else 'admins'
|
| 291 |
+
if prev.get('default') is True or raw.get('default') is True:
|
| 292 |
+
out['default'] = True
|
| 293 |
+
# An automation column carries the binding the engine reads; it is preserved across a patch
|
| 294 |
+
# rather than re-declared, because the automation editor owns it and this route does not.
|
| 295 |
+
auto = raw.get('automation') if 'automation' in raw else prev.get('automation')
|
| 296 |
+
if isinstance(auto, dict):
|
| 297 |
+
out['automation'] = auto
|
| 298 |
+
return out
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
|
| 302 |
+
"""May `viewer` change THIS column's definition? Creator/admin always; others only when the
|
| 303 |
+
field itself says `editRole: 'everyone'`. Fail-closed on an unknown field."""
|
| 304 |
+
if may_open(table_key, viewer, is_admin, st) and (
|
| 305 |
+
bool(is_admin) or (get(table_key, st) or {}).get('createdBy') == viewer):
|
| 306 |
+
return True
|
| 307 |
+
for f in ((get(table_key, st) or {}).get('fields') or []):
|
| 308 |
+
if f.get('key') == str(fkey):
|
| 309 |
+
return f.get('editRole') == 'everyone'
|
| 310 |
+
return False
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def add_field(table_key, raw, st=None):
|
| 314 |
+
"""Append one column to the shared schema. Returns the stored field, or None if refused."""
|
| 315 |
if not is_user_table(table_key, st):
|
| 316 |
return None
|
| 317 |
defn = get(table_key, st) or {}
|
| 318 |
+
have = [f for f in (defn.get('fields') or [])]
|
| 319 |
+
if len(have) >= MAX_FIELDS:
|
| 320 |
+
return None
|
| 321 |
+
field = _clean_field(raw)
|
| 322 |
+
if not field or any(f.get('key') == field['key'] for f in have):
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
def _add(cur):
|
| 326 |
+
t = cur.get(str(table_key))
|
| 327 |
+
if t is not None:
|
| 328 |
+
t.setdefault('fields', []).append(field)
|
| 329 |
+
return cur
|
| 330 |
+
|
| 331 |
+
_st(st).update(STORE_KEY, _add, flush='sync')
|
| 332 |
+
return field
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def patch_field(table_key, fkey, raw, st=None):
|
| 336 |
+
"""Edit one column's definition IN PLACE. Returns the stored field, or None if refused.
|
| 337 |
+
|
| 338 |
+
⚠ THE KEY NEVER MOVES. A field's key is what every stored cell is filed under, so accepting
|
| 339 |
+
a new one here would orphan every value in the column while looking like a rename. The
|
| 340 |
+
LABEL is the renameable thing; the key is identity.
|
| 341 |
+
"""
|
| 342 |
+
if not is_user_table(table_key, st):
|
| 343 |
+
return None
|
| 344 |
+
fields = [dict(f) for f in ((get(table_key, st) or {}).get('fields') or [])]
|
| 345 |
+
idx = next((i for i, f in enumerate(fields) if f.get('key') == str(fkey)), -1)
|
| 346 |
+
if idx < 0:
|
| 347 |
+
return None
|
| 348 |
+
merged = dict(raw or {})
|
| 349 |
+
merged['key'] = str(fkey)
|
| 350 |
+
field = _clean_field(merged, fields[idx])
|
| 351 |
+
if not field:
|
| 352 |
return None
|
| 353 |
+
|
| 354 |
+
def _set(cur):
|
| 355 |
+
t = cur.get(str(table_key))
|
| 356 |
+
if t is not None:
|
| 357 |
+
for i, f in enumerate(t.get('fields') or []):
|
| 358 |
+
if f.get('key') == str(fkey):
|
| 359 |
+
t['fields'][i] = field
|
| 360 |
+
break
|
| 361 |
+
return cur
|
| 362 |
+
|
| 363 |
+
_st(st).update(STORE_KEY, _set, flush='sync')
|
| 364 |
+
return field
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def delete_field(table_key, fkey, st=None):
|
| 368 |
+
"""Drop one column from the shared schema. Refuses to leave a table fieldless.
|
| 369 |
+
|
| 370 |
+
⚠ THE CELLS ARE LEFT IN THE ROWS ON PURPOSE. A deleted column whose values were also
|
| 371 |
+
scrubbed makes an accidental delete unrecoverable; the values are invisible without a field
|
| 372 |
+
declaring them, and re-adding the column with the same key brings them back. Same reasoning
|
| 373 |
+
as `routes_tables.delete_table` leaving the workspace bucket in place. Booked, not hidden.
|
| 374 |
+
"""
|
| 375 |
+
if not is_user_table(table_key, st):
|
| 376 |
+
return False
|
| 377 |
+
fields = (get(table_key, st) or {}).get('fields') or []
|
| 378 |
+
if len(fields) <= 1 or not any(f.get('key') == str(fkey) for f in fields):
|
| 379 |
+
return False
|
| 380 |
+
|
| 381 |
+
def _drop(cur):
|
| 382 |
+
t = cur.get(str(table_key))
|
| 383 |
+
if t is not None:
|
| 384 |
+
t['fields'] = [f for f in (t.get('fields') or []) if f.get('key') != str(fkey)]
|
| 385 |
+
return cur
|
| 386 |
+
|
| 387 |
+
_st(st).update(STORE_KEY, _drop, flush='sync')
|
| 388 |
+
return True
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def rename_choice_values(table_key, fkey, renames, st=None):
|
| 392 |
+
"""Rename select/multiselect OPTIONS **and migrate every stored cell** (contract C-RENAME).
|
| 393 |
+
|
| 394 |
+
`renames` = `[{'from': old, 'to': new}, …]` — an EXPLICIT MAPPING, never a diff. A diff
|
| 395 |
+
cannot tell "renamed Blue to Navy" from "deleted Blue and added Navy", and guessing wrong
|
| 396 |
+
empties a column silently.
|
| 397 |
+
|
| 398 |
+
Returns `{'options': n, 'cells': n}`. This is the DEFINITION half — the base options list and
|
| 399 |
+
the base row values. A user's per-user overlay stratum and any view filter naming the old
|
| 400 |
+
value are `core.table_store`'s half of the same contract; the caller runs both.
|
| 401 |
+
"""
|
| 402 |
+
pairs = []
|
| 403 |
+
for r in (renames or [])[:50]:
|
| 404 |
+
if not isinstance(r, dict):
|
| 405 |
+
continue
|
| 406 |
+
a = ' '.join(str(r.get('from') or '').split())[:60]
|
| 407 |
+
b = ' '.join(str(r.get('to') or '').split())[:60]
|
| 408 |
+
if a and b and a != b:
|
| 409 |
+
pairs.append((a, b))
|
| 410 |
+
if not pairs or not is_user_table(table_key, st):
|
| 411 |
+
return {'options': 0, 'cells': 0}
|
| 412 |
+
mapping = dict(pairs)
|
| 413 |
+
counts = {'options': 0, 'cells': 0}
|
| 414 |
|
| 415 |
def _apply(cur):
|
| 416 |
t = cur.get(str(table_key))
|
| 417 |
if t is None:
|
| 418 |
return cur
|
| 419 |
+
multi = False
|
| 420 |
+
for f in (t.get('fields') or []):
|
| 421 |
+
if f.get('key') != str(fkey):
|
| 422 |
+
continue
|
| 423 |
+
multi = f.get('type') == 'multiselect'
|
| 424 |
+
opts = f.get('options') or []
|
| 425 |
+
new_opts, seen = [], set()
|
| 426 |
+
for o in opts:
|
| 427 |
+
v = mapping.get(o, o)
|
| 428 |
+
if v not in seen: # a rename ONTO an existing option merges them
|
| 429 |
+
seen.add(v)
|
| 430 |
+
new_opts.append(v)
|
| 431 |
+
if o in mapping:
|
| 432 |
+
counts['options'] += 1
|
| 433 |
+
if opts:
|
| 434 |
+
f['options'] = new_opts
|
| 435 |
+
for row in (t.get('rows') or {}).values():
|
| 436 |
+
if not isinstance(row, dict) or str(fkey) not in row:
|
| 437 |
+
continue
|
| 438 |
+
cell = str(row.get(str(fkey)) or '')
|
| 439 |
+
if not cell:
|
| 440 |
+
continue
|
| 441 |
+
if multi:
|
| 442 |
+
# ⚠ MULTISELECT CELLS ARE COMMA-JOINED LABEL STRINGS, so a rename has to walk the
|
| 443 |
+
# parts. Rewriting the whole string would only ever hit a single-value cell.
|
| 444 |
+
parts = [p.strip() for p in cell.split(',')]
|
| 445 |
+
nxt = [mapping.get(p, p) for p in parts]
|
| 446 |
+
if nxt != parts:
|
| 447 |
+
row[str(fkey)] = ', '.join(dict.fromkeys(nxt))
|
| 448 |
+
counts['cells'] += 1
|
| 449 |
+
elif cell in mapping:
|
| 450 |
+
row[str(fkey)] = mapping[cell]
|
| 451 |
+
counts['cells'] += 1
|
| 452 |
return cur
|
| 453 |
|
| 454 |
_st(st).update(STORE_KEY, _apply, flush='sync')
|
|
|
|
| 469 |
return True
|
| 470 |
|
| 471 |
|
| 472 |
+
#: Names the ENGINE uses for itself when no human is on the other end of a run. ⛔ A table
|
| 473 |
+
#: stamped with one of these has NO HUMAN OWNER, so `may_open` can only admit an admin — which
|
| 474 |
+
#: is why the automation engine must never mint one (see `ut_ensure`'s owner rule, wave 20).
|
| 475 |
+
#: Named here rather than inline so the two modules that care about it read the SAME list.
|
| 476 |
+
MACHINE_OWNERS = ('automation', 'scheduler')
|
| 477 |
+
|
| 478 |
+
|
| 479 |
def may_open(table_key, viewer, is_admin=False, st=None):
|
| 480 |
+
"""FAIL-CLOSED visibility: the creator, or an admin. Nothing else. **THE one resolver.**
|
| 481 |
|
| 482 |
A user table cannot be gated by `allowed_modules` — a module grant written last month cannot
|
| 483 |
describe a table created this morning — so it needs its own rule, and the safe rule is the
|
|
|
|
| 485 |
a BU-scoped sales agent) could open a table somebody else created just by knowing its key.
|
| 486 |
Sharing a user table with named colleagues is a follow-on, and it should reuse the shared-
|
| 487 |
VIEW vocabulary rather than inventing a second one.
|
| 488 |
+
|
| 489 |
+
⛔ WAVE 20 — THERE WAS A SECOND, WIDER RULE, AND THE TWO DISAGREED. `routes_automation`'s
|
| 490 |
+
table picker admitted `createdBy in (uname, 'automation', 'scheduler')`, so a non-admin could
|
| 491 |
+
SEE an automation-created database in the picker and then be refused when they opened it,
|
| 492 |
+
edited it, or tried to delete it. Two ideas of who owns a table is the same defect class as
|
| 493 |
+
the two ideas of an agent's book (D-30): both surfaces look right in isolation and only
|
| 494 |
+
disagree in front of a user. The picker now calls THIS function, and the engine stamps a
|
| 495 |
+
real owner (see `MACHINE_OWNERS`) so nothing legitimate is narrowed by the merge.
|
| 496 |
"""
|
| 497 |
t = get(table_key, st)
|
| 498 |
if not t:
|
platform/harness/runtime.py
CHANGED
|
@@ -250,11 +250,27 @@ def get_runtime(tenant_key):
|
|
| 250 |
# R2: a tenant with its OWN repo gets a bound Store instance and an EMPTY prefix (the
|
| 251 |
# repo boundary is the namespace); everyone else keeps the t/<slug>/ prefix in the
|
| 252 |
# shared repo. Tenant #0 keeps both defaults — empty prefix, shared repo.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
repo = (getattr(tenant, "config", {}) or {}).get("store_repo")
|
| 254 |
-
|
| 255 |
-
key=key, tenant=tenant,
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
_CACHE[key] = rt
|
| 259 |
while len(_CACHE) > _MAX_RUNTIMES:
|
| 260 |
_CACHE.popitem(last=False) # evict least recently used
|
|
|
|
| 250 |
# R2: a tenant with its OWN repo gets a bound Store instance and an EMPTY prefix (the
|
| 251 |
# repo boundary is the namespace); everyone else keeps the t/<slug>/ prefix in the
|
| 252 |
# shared repo. Tenant #0 keeps both defaults — empty prefix, shared repo.
|
| 253 |
+
#
|
| 254 |
+
# ⭐ WAVE 20 (R1 / D-4): under Postgres the address is a SCHEMA, so `store.handle()` is
|
| 255 |
+
# asked for one by SLUG and the namespace prefix goes to EMPTY for EVERY tenant —
|
| 256 |
+
# `t_<slug>.store_kv` already isolates, and prefixing keys inside an isolated schema
|
| 257 |
+
# would namespace them twice. That is not a cosmetic difference: a `t/nurilab/`-prefixed
|
| 258 |
+
# key written into `t_nurilab` is a key the migration did not copy and no reader looks
|
| 259 |
+
# for, i.e. a silent empty workspace.
|
| 260 |
+
#
|
| 261 |
+
# ⚠ NOTHING ELSE IN THIS FILE CHANGES, and that is the design working. Every product-data
|
| 262 |
+
# read/write already goes through `rt.get/put/update/exists` (the `st=` handle threaded
|
| 263 |
+
# through the modules), so binding the handle correctly here IS the cutover for the
|
| 264 |
+
# tenant-aware paths — the module-level `core.store` functions cover the rest via `_d()`.
|
| 265 |
repo = (getattr(tenant, "config", {}) or {}).get("store_repo")
|
| 266 |
+
if store.backend() == "pg":
|
| 267 |
+
rt = TenantRuntime(key=key, tenant=tenant, store_namespace="",
|
| 268 |
+
store_handle=store.handle(slug=key))
|
| 269 |
+
else:
|
| 270 |
+
rt = TenantRuntime(
|
| 271 |
+
key=key, tenant=tenant,
|
| 272 |
+
store_namespace=("" if (key == "royal-imports" or repo) else f"t/{key}/"),
|
| 273 |
+
store_handle=(store.for_repo(repo) if repo else None))
|
| 274 |
_CACHE[key] = rt
|
| 275 |
while len(_CACHE) > _MAX_RUNTIMES:
|
| 276 |
_CACHE.popitem(last=False) # evict least recently used
|
platform/modules/customer_data.py
CHANGED
|
@@ -272,6 +272,94 @@ def _dba_attrs(pids, t):
|
|
| 272 |
return {}
|
| 273 |
|
| 274 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
def _pool_build(agent_name, team_id, limit, fast=False):
|
| 276 |
t = P.today()
|
| 277 |
yf, yt = P.ytd(t)
|
|
@@ -282,7 +370,7 @@ def _pool_build(agent_name, team_id, limit, fast=False):
|
|
| 282 |
last = cust._cust_rev(lf, lt, team_id, pids)
|
| 283 |
ltm = cust._cust_rev(mf, mt, team_id, pids)
|
| 284 |
cad = cust._cadence_bulk(t, team_id, agent_pids=pids)
|
| 285 |
-
all_pids = set(this) | set(last) | set(cad)
|
| 286 |
total = len(all_pids)
|
| 287 |
if limit is not None and total > limit:
|
| 288 |
# the SAME ordering the assembled pool ships (see the sort below) — the slice must be
|
|
|
|
| 272 |
return {}
|
| 273 |
|
| 274 |
|
| 275 |
+
def _book_pids(team_id=None, agent_pids=None):
|
| 276 |
+
"""⭐ WAVE 20 (owner items 8 + 27, ruling R3) — THE WHOLE BOOK, not just who bought recently.
|
| 277 |
+
|
| 278 |
+
Owner: *"Martin Pasternak showing only 313 accounts when I filter agent, whereas in Odoo it is
|
| 279 |
+
494 … I want our App to show complete source of truth, even if no sale.order at all or not.
|
| 280 |
+
Its important to see what customer is getting assigned to Martin or whether we can retarget
|
| 281 |
+
them."* The pool was a 24-MONTH SALES universe (YTD ∪ LY-YTD ∪ cadence-window buyers), so a
|
| 282 |
+
customer assigned to an agent who has never ordered — precisely a retargeting target — did not
|
| 283 |
+
exist in the app at all.
|
| 284 |
+
|
| 285 |
+
R3's definition, MEASURED against live Odoo 2026-08-05:
|
| 286 |
+
customer_rank>0 active .............. 3,614
|
| 287 |
+
ever ordered (confirmed, team 5/6) ... 1,748
|
| 288 |
+
UNION ............................... 3,723 (vs 1,555 in the old 24-month pool)
|
| 289 |
+
Martin's book under this rule is **494** — the owner's Odoo number, to the account.
|
| 290 |
+
|
| 291 |
+
⛔ **THE "agent-assigned" LEG OF R3 IS DELIBERATELY NOT A THIRD TERM, and that is a
|
| 292 |
+
measurement, not a shortcut.** Taken literally it added 174 partners and pushed Martin to 503;
|
| 293 |
+
every one of the 9 extras on his book was an ODOO ADDRESS RECORD rather than an account —
|
| 294 |
+
`type` in (`delivery`, `other`), most carrying a `parent_id`, and TWO with `name: False`.
|
| 295 |
+
Shipping them would have put nameless rows in the customer table and made "how many customers
|
| 296 |
+
does Martin have" answer 503 against an Odoo screen that says 494.
|
| 297 |
+
Excluding address types wholesale is also wrong (`type not in (delivery, invoice, other,
|
| 298 |
+
private)` measured **488** — it drops 6 genuine accounts that happen to carry a delivery
|
| 299 |
+
type). `customer_rank > 0` is the predicate that means "this partner is a customer record",
|
| 300 |
+
it reproduces the owner's number exactly, and every agent-assigned ACCOUNT already satisfies
|
| 301 |
+
it — so the leg is subsumed rather than dropped. Item 27's actual ask (the 126 assigned
|
| 302 |
+
partners with no `sale.order` at all) is fully served: they are rank>0 and they are in.
|
| 303 |
+
|
| 304 |
+
⚠ ACTIVE ONLY. Archived partners stay out (R3): they are ex-customers, and putting them in
|
| 305 |
+
every count would make "how many customers do we have" unanswerable. The agent-LOGIN scope
|
| 306 |
+
(`customers.agent_partner_ids`) deliberately still includes archived — an agent's own book is
|
| 307 |
+
their whole history — and the two remain compatible because that set is INTERSECTED with this
|
| 308 |
+
pool, so archived rows drop out of the table without narrowing the agent's own permissions.
|
| 309 |
+
|
| 310 |
+
⚠ BU SCOPE IS APPLIED THROUGH ORDERS, NOT THROUGH THE PARTNER. `res.partner` carries no team,
|
| 311 |
+
so a BU-scoped caller gets the partners who have ORDERED in that BU (plus their own agent
|
| 312 |
+
book). Widening it to every partner for a scoped user would cross the BU isolation rule that
|
| 313 |
+
the whole permissioning model rests on.
|
| 314 |
+
"""
|
| 315 |
+
try:
|
| 316 |
+
if team_id:
|
| 317 |
+
# Scoped: partners with confirmed orders in THIS BU, ever. `read_group` on partner_id
|
| 318 |
+
# rather than a search_read of orders — the group is the distinct set, and it is one
|
| 319 |
+
# round trip instead of paging tens of thousands of order rows.
|
| 320 |
+
rows = O.read_group('sale.order',
|
| 321 |
+
[('state', 'in', ('sale', 'done')), ('team_id', '=', team_id)],
|
| 322 |
+
['partner_id'], ['partner_id'], lazy=False)
|
| 323 |
+
book = {O.m2o_id(r.get('partner_id')) for r in rows}
|
| 324 |
+
book.discard(None)
|
| 325 |
+
else:
|
| 326 |
+
rank = O.search_read('res.partner',
|
| 327 |
+
[('customer_rank', '>', 0), ('active', '=', True)],
|
| 328 |
+
['id'], limit=200000)
|
| 329 |
+
# `ever` is NOT redundant with rank>0: a partner can be archived-then-reactivated, or
|
| 330 |
+
# have had its rank reset, and an account that demonstrably bought from us belongs in
|
| 331 |
+
# the book whatever its flags say now. An ORDER is a fact; a rank is a setting.
|
| 332 |
+
rows = O.read_group('sale.order',
|
| 333 |
+
[('state', 'in', ('sale', 'done')), ('team_id', 'in', (5, 6))],
|
| 334 |
+
['partner_id'], ['partner_id'], lazy=False)
|
| 335 |
+
ever = {O.m2o_id(r.get('partner_id')) for r in rows}
|
| 336 |
+
ever.discard(None)
|
| 337 |
+
book = {r['id'] for r in rank} | ever
|
| 338 |
+
# An agent filter NARROWS the book to that agent's partners — never widens it. The
|
| 339 |
+
# intersection is what keeps an agent-login user inside their own book while still
|
| 340 |
+
# gaining every no-order account assigned to them, which is the whole point of item 27.
|
| 341 |
+
return (book & set(agent_pids)) if agent_pids is not None else book
|
| 342 |
+
except Exception as e:
|
| 343 |
+
# Degrade to the sales-derived universe rather than failing the page — but LOUDLY.
|
| 344 |
+
#
|
| 345 |
+
# ⛔ THIS DEGRADATION IS NOT LIKE THE OTHERS IN THIS MODULE, and the difference is what
|
| 346 |
+
# makes silence wrong here. Every other family (`_ar_attrs`, `_mix_attrs`, …) degrades to
|
| 347 |
+
# a blank COLUMN, which is visible: the user sees an empty column and asks why. This one
|
| 348 |
+
# degrades to absent ROWS. The pool still builds from the sales legs, so the page renders
|
| 349 |
+
# perfectly, the counts look plausible, and Martin is quietly back at 313 with nothing
|
| 350 |
+
# anywhere saying the book leg failed. An invisible degradation of a COUNT is the failure
|
| 351 |
+
# mode this codebase keeps writing rules against ([[no-unverifiable-aggregates]]).
|
| 352 |
+
try:
|
| 353 |
+
import harness.telemetry as _tel
|
| 354 |
+
_tel.error('customer_data:_book_pids', e, fallback='sales-derived pool only')
|
| 355 |
+
except Exception:
|
| 356 |
+
pass
|
| 357 |
+
print(f"[aios] WARNING customer_data._book_pids failed ({type(e).__name__}: {e}) - the "
|
| 358 |
+
f"customer pool is falling back to the 24-month SALES universe, so accounts with "
|
| 359 |
+
f"no recent orders (owner item 27) are MISSING from this build.")
|
| 360 |
+
return set()
|
| 361 |
+
|
| 362 |
+
|
| 363 |
def _pool_build(agent_name, team_id, limit, fast=False):
|
| 364 |
t = P.today()
|
| 365 |
yf, yt = P.ytd(t)
|
|
|
|
| 370 |
last = cust._cust_rev(lf, lt, team_id, pids)
|
| 371 |
ltm = cust._cust_rev(mf, mt, team_id, pids)
|
| 372 |
cad = cust._cadence_bulk(t, team_id, agent_pids=pids)
|
| 373 |
+
all_pids = set(this) | set(last) | set(cad) | _book_pids(team_id, pids)
|
| 374 |
total = len(all_pids)
|
| 375 |
if limit is not None and total > limit:
|
| 376 |
# the SAME ordering the assembled pool ships (see the sort below) — the slice must be
|
platform/modules/customers.py
CHANGED
|
@@ -89,11 +89,30 @@ def agent_options(t=None, team_id=None):
|
|
| 89 |
return [r['group'] for r in by_dimension('agent', t, team_id=team_id)]
|
| 90 |
|
| 91 |
|
| 92 |
-
def agent_partner_ids(agent_name):
|
| 93 |
"""frozenset of partner ids assigned to `agent_name` via res.partner.agent_ids. None when no
|
| 94 |
agent is selected ('All agents'); an EMPTY frozenset (matches nobody) when the agent has no
|
| 95 |
customers. '(none)' resolves to customers with no agent assigned.
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
INCLUDES INACTIVE/archived partners (active in [True,False]) — the agent's book is the whole
|
| 98 |
book (dormant + archived accounts too), which is what the Agents page promises AND what an
|
| 99 |
AGENT-LOGIN user must see as their complete, isolated book. Active-only used to drop archived
|
|
@@ -103,6 +122,9 @@ def agent_partner_ids(agent_name):
|
|
| 103 |
if not agent_name or agent_name in ('All agents', 'All'):
|
| 104 |
return None
|
| 105 |
_all = [('active', 'in', [True, False])]
|
|
|
|
|
|
|
|
|
|
| 106 |
if agent_name == '(none)':
|
| 107 |
rows = O.search_read('res.partner', [('customer_rank', '>', 0),
|
| 108 |
('agent_ids', '=', False)] + _all, ['id'], limit=100000)
|
|
@@ -110,7 +132,7 @@ def agent_partner_ids(agent_name):
|
|
| 110 |
ag = O.search_read('res.partner', [('name', '=', agent_name)] + _all, ['id'], limit=10)
|
| 111 |
if not ag:
|
| 112 |
return frozenset()
|
| 113 |
-
rows = O.search_read('res.partner', [('agent_ids', 'in', [a['id'] for a in ag])] +
|
| 114 |
['id'], limit=100000)
|
| 115 |
return frozenset(r['id'] for r in rows)
|
| 116 |
|
|
@@ -591,7 +613,18 @@ def _partner_attrs(pids):
|
|
| 591 |
'city': city.title() if city else '(none)',
|
| 592 |
'state': O.m2o_name(r.get('state_id')) or '(none)',
|
| 593 |
'country': O.m2o_name(r.get('country_id')) or '(none)',
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
'zip': (r.get('zip') or '').strip() or '(none)',
|
| 596 |
'payment_terms': O.m2o_name(r.get('property_payment_term_id')) or '(none)',
|
| 597 |
'customer_since': str(since)[:10] if since else '',
|
|
|
|
| 89 |
return [r['group'] for r in by_dimension('agent', t, team_id=team_id)]
|
| 90 |
|
| 91 |
|
| 92 |
+
def agent_partner_ids(agent_name, customers_only=False):
|
| 93 |
"""frozenset of partner ids assigned to `agent_name` via res.partner.agent_ids. None when no
|
| 94 |
agent is selected ('All agents'); an EMPTY frozenset (matches nobody) when the agent has no
|
| 95 |
customers. '(none)' resolves to customers with no agent assigned.
|
| 96 |
|
| 97 |
+
⭐ WAVE 20 (R3, DEBT D-30) — `customers_only` IS THE EXPLICIT POLICY THIS FUNCTION WAS
|
| 98 |
+
MISSING, and the two callers genuinely want different answers:
|
| 99 |
+
|
| 100 |
+
* **Permission scope (default, `False`)** — an agent-LOGIN user's whole app is bounded by
|
| 101 |
+
this set, so it must be GENEROUS: archived accounts and Odoo address records included.
|
| 102 |
+
Narrowing it would hide an agent's own data from them, and the fails-closed trap in the
|
| 103 |
+
note below is what that costs.
|
| 104 |
+
* **Display/reporting (`True`)** — "how many accounts does Martin have" must answer what
|
| 105 |
+
Odoo answers. MEASURED 2026-08-05: the generous set is 503 for Martin and the honest one
|
| 106 |
+
is **494**, the owner's number; the 9-row gap is entirely `type in (delivery, other)`
|
| 107 |
+
ADDRESS records that ride the pool because they appear on orders, two of them with no
|
| 108 |
+
name at all.
|
| 109 |
+
|
| 110 |
+
Both are the SAME m2m-contains resolver — which is D-30's actual requirement. The bug was
|
| 111 |
+
never the generosity; it was that the Agent COLUMN used a THIRD rule (`agent_ids[0]`, first
|
| 112 |
+
agent only) that agreed with neither, so an admin filtering `Agent = X` and X's own login saw
|
| 113 |
+
different books. The column now lists every agent on the partner and this states its policy
|
| 114 |
+
out loud, so the two can be reconciled by reading them instead of by measuring them.
|
| 115 |
+
|
| 116 |
INCLUDES INACTIVE/archived partners (active in [True,False]) — the agent's book is the whole
|
| 117 |
book (dormant + archived accounts too), which is what the Agents page promises AND what an
|
| 118 |
AGENT-LOGIN user must see as their complete, isolated book. Active-only used to drop archived
|
|
|
|
| 122 |
if not agent_name or agent_name in ('All agents', 'All'):
|
| 123 |
return None
|
| 124 |
_all = [('active', 'in', [True, False])]
|
| 125 |
+
# The display policy: a real customer record, still active. See the docstring for the
|
| 126 |
+
# measured 503-vs-494 this closes.
|
| 127 |
+
_qual = [('customer_rank', '>', 0), ('active', '=', True)] if customers_only else _all
|
| 128 |
if agent_name == '(none)':
|
| 129 |
rows = O.search_read('res.partner', [('customer_rank', '>', 0),
|
| 130 |
('agent_ids', '=', False)] + _all, ['id'], limit=100000)
|
|
|
|
| 132 |
ag = O.search_read('res.partner', [('name', '=', agent_name)] + _all, ['id'], limit=10)
|
| 133 |
if not ag:
|
| 134 |
return frozenset()
|
| 135 |
+
rows = O.search_read('res.partner', [('agent_ids', 'in', [a['id'] for a in ag])] + _qual,
|
| 136 |
['id'], limit=100000)
|
| 137 |
return frozenset(r['id'] for r in rows)
|
| 138 |
|
|
|
|
| 613 |
'city': city.title() if city else '(none)',
|
| 614 |
'state': O.m2o_name(r.get('state_id')) or '(none)',
|
| 615 |
'country': O.m2o_name(r.get('country_id')) or '(none)',
|
| 616 |
+
# ⭐ WAVE 20 (R3, closes DEBT D-30) — EVERY agent on the partner, not just the first.
|
| 617 |
+
#
|
| 618 |
+
# This was `anames.get(ag[0])`, and it made TWO definitions of "an agent's book" that
|
| 619 |
+
# disagreed: `agent_partner_ids()` (which scopes an agent-LOGIN user's whole app) is
|
| 620 |
+
# m2m-CONTAINS, while this column named only `agent_ids[0]`. So an admin filtering
|
| 621 |
+
# `Agent = X` and X's own login saw different sets — MEASURED book-wide 2026-08-05:
|
| 622 |
+
# Tara Devon Gallager 9 rows, Sang Ching 6, Moishe Rubenstein 1, Martin Pasternak 1.
|
| 623 |
+
#
|
| 624 |
+
# Joined with ', ' rather than kept as a list because the column is a TEXT field the
|
| 625 |
+
# grid groups and filters on; `contains` then matches any agent on a shared account,
|
| 626 |
+
# which is the m2m question asked in the vocabulary the column already speaks.
|
| 627 |
+
'agent': ', '.join(n for n in (anames.get(a) for a in ag) if n) or '(none)',
|
| 628 |
'zip': (r.get('zip') or '').strip() or '(none)',
|
| 629 |
'payment_terms': O.m2o_name(r.get('property_payment_term_id')) or '(none)',
|
| 630 |
'customer_since': str(since)[:10] if since else '',
|
requirements.txt
CHANGED
|
@@ -24,3 +24,14 @@ pillow>=10.0
|
|
| 24 |
beautifulsoup4>=4.12
|
| 25 |
lxml>=5.0
|
| 26 |
cryptography>=42.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
beautifulsoup4>=4.12
|
| 25 |
lxml>=5.0
|
| 26 |
cryptography>=42.0
|
| 27 |
+
# ⭐ WAVE 20 (R1 / D-4) — THE POSTGRES DRIVER, AND IT IS LOAD-BEARING IN THIS FILE SPECIFICALLY.
|
| 28 |
+
# `core/store_pg.py` is the store backend from this wave on, and it is deliberately FAIL-CLOSED:
|
| 29 |
+
# `_pool()` raises rather than falling back to the HF file store, so a container that gets
|
| 30 |
+
# `STORE_BACKEND=pg` without this line does not degrade — it refuses every request that touches
|
| 31 |
+
# the store, which is every authenticated request.
|
| 32 |
+
#
|
| 33 |
+
# ⛔ AND THIS IS THE FILE THAT MATTERS: the Dockerfile does `COPY requirements.txt` from the
|
| 34 |
+
# Space root, i.e. THIS manifest, not `api/requirements.txt`. That header's own streamlit story
|
| 35 |
+
# is the same defect in the other direction — the pinned intent and the shipped manifest are two
|
| 36 |
+
# documents. psycopg is now in BOTH, and `ops/verify_portability.py` gates the pair.
|
| 37 |
+
psycopg[binary,pool]>=3.2
|
web/src/alerts/AlertsPane.tsx
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// alerts/AlertsPane.tsx — WAVE 20 item 25 (C-ALERT): the inbox.
|
| 3 |
+
//
|
| 4 |
+
// Two lists, one panel, and the order is the point: what HAPPENED first, what is
|
| 5 |
+
// WATCHING second. An inbox that opens on its own configuration is a settings
|
| 6 |
+
// screen wearing a bell.
|
| 7 |
+
//
|
| 8 |
+
// A drawer rather than a route, for the same reason the AI note is a modal: the
|
| 9 |
+
// nav is server-filtered and an undeclared surface is denied by design (this
|
| 10 |
+
// shell has no client-invented pages). Alerts are not a granted module — they
|
| 11 |
+
// are this account's own inbox over its own views.
|
| 12 |
+
// ---------------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
import { useCallback, useEffect, useState } from "react";
|
| 15 |
+
import {
|
| 16 |
+
deleteAlert,
|
| 17 |
+
fetchAlerts,
|
| 18 |
+
fetchInbox,
|
| 19 |
+
markRead,
|
| 20 |
+
runAlert,
|
| 21 |
+
} from "./alertsApi";
|
| 22 |
+
import { EMPTY_INBOX, applyRead, inboxOrder, routeForTopic, stampText } from "./alertsModel";
|
| 23 |
+
import type { Alert, Inbox, Notification } from "./alertsModel";
|
| 24 |
+
|
| 25 |
+
/** The clock face this pane uses for its one glyph — drawn, never an emoji. */
|
| 26 |
+
function BellIcon() {
|
| 27 |
+
return (
|
| 28 |
+
<svg className="alerts-bell" viewBox="0 0 16 16" aria-hidden="true">
|
| 29 |
+
<path d="M8 2.2a3.6 3.6 0 0 1 3.6 3.6v2.4l1.2 2H3.2l1.2-2V5.8A3.6 3.6 0 0 1 8 2.2Z" />
|
| 30 |
+
<path d="M6.6 12.6a1.5 1.5 0 0 0 2.8 0" />
|
| 31 |
+
</svg>
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default function AlertsPane({
|
| 36 |
+
onClose,
|
| 37 |
+
onOpenView,
|
| 38 |
+
onInbox,
|
| 39 |
+
onToast,
|
| 40 |
+
}: {
|
| 41 |
+
onClose: () => void;
|
| 42 |
+
/** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
|
| 43 |
+
onOpenView: (topic: string, viewId: string) => void;
|
| 44 |
+
/** Hand the freshly-read inbox back so the nav badge and the pane agree. */
|
| 45 |
+
onInbox: (inbox: Inbox) => void;
|
| 46 |
+
onToast: (message: string) => void;
|
| 47 |
+
}) {
|
| 48 |
+
const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
|
| 49 |
+
const [alerts, setAlerts] = useState<Alert[]>([]);
|
| 50 |
+
const [error, setError] = useState("");
|
| 51 |
+
const [busy, setBusy] = useState(false);
|
| 52 |
+
|
| 53 |
+
const publish = useCallback(
|
| 54 |
+
(next: Inbox) => {
|
| 55 |
+
setInbox(next);
|
| 56 |
+
onInbox(next);
|
| 57 |
+
},
|
| 58 |
+
[onInbox]
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
const load = useCallback(async () => {
|
| 62 |
+
const [inboxRes, alertRes] = await Promise.all([fetchInbox(), fetchAlerts()]);
|
| 63 |
+
if (inboxRes.ok) publish(inboxRes.value);
|
| 64 |
+
else setError(inboxRes.message);
|
| 65 |
+
if (alertRes.ok) setAlerts(alertRes.value);
|
| 66 |
+
}, [publish]);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
void load();
|
| 70 |
+
}, [load]);
|
| 71 |
+
|
| 72 |
+
// ⛔ Escape closes it, like every other panel in this shell — a scrim with no
|
| 73 |
+
// keyboard way out is a trap (the wave-18 lesson, kept).
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
const onKey = (e: KeyboardEvent) => {
|
| 76 |
+
if (e.key === "Escape") onClose();
|
| 77 |
+
};
|
| 78 |
+
window.addEventListener("keydown", onKey);
|
| 79 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 80 |
+
}, [onClose]);
|
| 81 |
+
|
| 82 |
+
const toggleRead = useCallback(
|
| 83 |
+
(n: Notification) => {
|
| 84 |
+
// Optimistic, with the server's own convention (`ids`, `read`) sent
|
| 85 |
+
// explicitly — and reverted out loud if the write is refused, because a
|
| 86 |
+
// badge that silently disagrees with the list is how a reader learns to
|
| 87 |
+
// stop trusting it.
|
| 88 |
+
const before = inbox;
|
| 89 |
+
publish(applyRead(inbox, [n.id], !n.read));
|
| 90 |
+
void markRead([n.id], !n.read).then((r) => {
|
| 91 |
+
if (!r.ok) {
|
| 92 |
+
publish(before);
|
| 93 |
+
onToast(r.message);
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
},
|
| 97 |
+
[inbox, publish, onToast]
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
const markAll = useCallback(() => {
|
| 101 |
+
const before = inbox;
|
| 102 |
+
publish(applyRead(inbox, null, true));
|
| 103 |
+
void markRead(null, true).then((r) => {
|
| 104 |
+
if (!r.ok) {
|
| 105 |
+
publish(before);
|
| 106 |
+
onToast(r.message);
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
}, [inbox, publish, onToast]);
|
| 110 |
+
|
| 111 |
+
const open = useCallback(
|
| 112 |
+
(n: Notification) => {
|
| 113 |
+
if (!n.read) {
|
| 114 |
+
publish(applyRead(inbox, [n.id], true));
|
| 115 |
+
void markRead([n.id], true);
|
| 116 |
+
}
|
| 117 |
+
// A route this client cannot resolve opens NOTHING and says so — alerts
|
| 118 |
+
// outlive the surfaces they were made from.
|
| 119 |
+
if (!routeForTopic(n.topic)) {
|
| 120 |
+
onToast("That alert's table is no longer available to this account.");
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
onOpenView(n.topic, n.viewId);
|
| 124 |
+
onClose();
|
| 125 |
+
},
|
| 126 |
+
[inbox, publish, onOpenView, onClose, onToast]
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
const rows = inboxOrder(inbox.items);
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<div className="shell-newdb-scrim" onClick={onClose}>
|
| 133 |
+
<aside
|
| 134 |
+
className="alerts-pane"
|
| 135 |
+
role="dialog"
|
| 136 |
+
aria-label="Alerts"
|
| 137 |
+
onClick={(e) => e.stopPropagation()}
|
| 138 |
+
>
|
| 139 |
+
<header className="alerts-head">
|
| 140 |
+
<h2>
|
| 141 |
+
<BellIcon /> Alerts
|
| 142 |
+
</h2>
|
| 143 |
+
<button
|
| 144 |
+
type="button"
|
| 145 |
+
className="alerts-markall"
|
| 146 |
+
disabled={inbox.unread === 0}
|
| 147 |
+
onClick={markAll}
|
| 148 |
+
>
|
| 149 |
+
Mark all read
|
| 150 |
+
</button>
|
| 151 |
+
</header>
|
| 152 |
+
|
| 153 |
+
{error ? <p className="shell-newdb-err">{error}</p> : null}
|
| 154 |
+
|
| 155 |
+
<div className="alerts-scroll">
|
| 156 |
+
{rows.length === 0 ? (
|
| 157 |
+
<p className="alerts-empty">
|
| 158 |
+
Nothing new. An alert watches ONE view and tells you when a record it had
|
| 159 |
+
never matched arrives in it — make one from a view's ··· menu.
|
| 160 |
+
</p>
|
| 161 |
+
) : (
|
| 162 |
+
<ul className="alerts-list">
|
| 163 |
+
{rows.map((n) => (
|
| 164 |
+
<li key={n.id} className={"alerts-row" + (n.read ? "" : " is-unread")}>
|
| 165 |
+
<button
|
| 166 |
+
type="button"
|
| 167 |
+
className="alerts-row-main"
|
| 168 |
+
onClick={() => open(n)}
|
| 169 |
+
title={`Open ${n.alertLabel || "the alert's view"}`}
|
| 170 |
+
>
|
| 171 |
+
<span className="alerts-row-label">{n.label}</span>
|
| 172 |
+
<span className="alerts-row-meta">
|
| 173 |
+
{n.alertLabel ? `${n.alertLabel} · ` : ""}
|
| 174 |
+
{/* Readable, but never re-derived: `stampText` is string surgery
|
| 175 |
+
over the server's own UTC-with-offset stamp (D-18). Parsing it
|
| 176 |
+
into a browser Date is how a tenant a day ahead gets told an
|
| 177 |
+
event happened tomorrow. */}
|
| 178 |
+
{stampText(n.at)}
|
| 179 |
+
</span>
|
| 180 |
+
</button>
|
| 181 |
+
<button
|
| 182 |
+
type="button"
|
| 183 |
+
className="alerts-row-toggle"
|
| 184 |
+
aria-label={n.read ? `Mark ${n.label} unread` : `Mark ${n.label} read`}
|
| 185 |
+
title={n.read ? "Mark unread" : "Mark read"}
|
| 186 |
+
onClick={() => toggleRead(n)}
|
| 187 |
+
>
|
| 188 |
+
{/* The ACTION, not the state: "Read" beside an unread row reads as
|
| 189 |
+
a label for the row itself. */}
|
| 190 |
+
{n.read ? "Mark unread" : "Mark read"}
|
| 191 |
+
</button>
|
| 192 |
+
</li>
|
| 193 |
+
))}
|
| 194 |
+
</ul>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{alerts.length > 0 ? (
|
| 198 |
+
<section className="alerts-watching">
|
| 199 |
+
<h3>Watching</h3>
|
| 200 |
+
{alerts.map((a) => (
|
| 201 |
+
<div key={a.id} className="alerts-watch-row">
|
| 202 |
+
<span className="alerts-watch-label">{a.label}</span>
|
| 203 |
+
<span className="alerts-watch-meta">
|
| 204 |
+
{/* The remembered set's SIZE, which is what "no news" means here:
|
| 205 |
+
an alert with 40 matches and nothing new is working. */}
|
| 206 |
+
{a.matched.toLocaleString()} matched
|
| 207 |
+
{a.lastError ? ` · ${a.lastError}` : ""}
|
| 208 |
+
</span>
|
| 209 |
+
<button
|
| 210 |
+
type="button"
|
| 211 |
+
className="alerts-watch-run"
|
| 212 |
+
disabled={busy}
|
| 213 |
+
onClick={() => {
|
| 214 |
+
setBusy(true);
|
| 215 |
+
void runAlert(a.id).then((r) => {
|
| 216 |
+
setBusy(false);
|
| 217 |
+
if (!r.ok) return onToast(r.message);
|
| 218 |
+
const v = r.value as { new?: unknown[]; skipped?: string };
|
| 219 |
+
onToast(
|
| 220 |
+
v?.skipped
|
| 221 |
+
? `Skipped: ${v.skipped}`
|
| 222 |
+
: `${(v?.new ?? []).length} new since the last check.`
|
| 223 |
+
);
|
| 224 |
+
void load();
|
| 225 |
+
});
|
| 226 |
+
}}
|
| 227 |
+
>
|
| 228 |
+
Check now
|
| 229 |
+
</button>
|
| 230 |
+
<button
|
| 231 |
+
type="button"
|
| 232 |
+
className="alerts-watch-del"
|
| 233 |
+
disabled={busy}
|
| 234 |
+
aria-label={`Delete the alert ${a.label}`}
|
| 235 |
+
onClick={() => {
|
| 236 |
+
setBusy(true);
|
| 237 |
+
void deleteAlert(a.id).then((r) => {
|
| 238 |
+
setBusy(false);
|
| 239 |
+
if (!r.ok) return onToast(r.message);
|
| 240 |
+
setAlerts((cur) => cur.filter((x) => x.id !== a.id));
|
| 241 |
+
void load();
|
| 242 |
+
});
|
| 243 |
+
}}
|
| 244 |
+
>
|
| 245 |
+
Delete
|
| 246 |
+
</button>
|
| 247 |
+
</div>
|
| 248 |
+
))}
|
| 249 |
+
</section>
|
| 250 |
+
) : null}
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div className="shell-newdb-actions alerts-foot">
|
| 254 |
+
<button type="button" className="login-submit" onClick={onClose}>
|
| 255 |
+
Close
|
| 256 |
+
</button>
|
| 257 |
+
</div>
|
| 258 |
+
</aside>
|
| 259 |
+
</div>
|
| 260 |
+
);
|
| 261 |
+
}
|
web/src/alerts/alertsApi.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// alerts/alertsApi.ts — WAVE 20 item 25 (C-ALERT): the seven calls, and nothing
|
| 3 |
+
// else. Every one carries the session cookie and fails CLOSED: an unreadable
|
| 4 |
+
// answer is an empty inbox, never a half-parsed one.
|
| 5 |
+
//
|
| 6 |
+
// Shaped like `shell/session.ts` on purpose — the same `{ok}` result type, the
|
| 7 |
+
// same 4xx/5xx message policy — because the failure that matters is identical:
|
| 8 |
+
// a wrong `credentials` word produces no client-side symptom at all, just a 401
|
| 9 |
+
// from a server that never saw a session.
|
| 10 |
+
// ---------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 13 |
+
import { EMPTY_INBOX, parseAlerts, parseInbox } from "./alertsModel";
|
| 14 |
+
import type { Alert, Inbox } from "./alertsModel";
|
| 15 |
+
|
| 16 |
+
export type Result<T> =
|
| 17 |
+
| { ok: true; value: T }
|
| 18 |
+
| { ok: false; status: number; message: string };
|
| 19 |
+
|
| 20 |
+
/** 4xx text is POLICY the reader needs ("this view has no filter"); a 5xx's text
|
| 21 |
+
* is the server's internals and is never shown. Same split as session.ts. */
|
| 22 |
+
export function errorMessage(status: number, message?: string): string {
|
| 23 |
+
if (status >= 500 || !message) {
|
| 24 |
+
return status >= 500
|
| 25 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 26 |
+
: `The server answered ${status}.`;
|
| 27 |
+
}
|
| 28 |
+
return message;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function call<T>(
|
| 32 |
+
path: string,
|
| 33 |
+
init: RequestInit,
|
| 34 |
+
read: (body: unknown) => T
|
| 35 |
+
): Promise<Result<T>> {
|
| 36 |
+
let res: Response;
|
| 37 |
+
try {
|
| 38 |
+
res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 39 |
+
} catch {
|
| 40 |
+
return { ok: false, status: 0, message: "Cannot reach the server." };
|
| 41 |
+
}
|
| 42 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 43 |
+
if (!res.ok) {
|
| 44 |
+
const detail = (body as { error?: { message?: string } } | null)?.error?.message;
|
| 45 |
+
return { ok: false, status: res.status, message: errorMessage(res.status, detail) };
|
| 46 |
+
}
|
| 47 |
+
return { ok: true, value: read(body) };
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const json = (data: unknown): RequestInit => ({
|
| 51 |
+
method: "POST",
|
| 52 |
+
headers: { "Content-Type": "application/json" },
|
| 53 |
+
body: JSON.stringify(data),
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
export function fetchInbox(): Promise<Result<Inbox>> {
|
| 57 |
+
return call("/notifications", {}, parseInbox);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** `ids: null` marks EVERYTHING — the API's own convention, not a shortcut. */
|
| 61 |
+
export function markRead(ids: string[] | null, read = true): Promise<Result<true>> {
|
| 62 |
+
return call("/notifications/read", json({ ids, read }), () => true as const);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function fetchAlerts(): Promise<Result<Alert[]>> {
|
| 66 |
+
return call("/alerts", {}, parseAlerts);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* `POST /alerts`. ⚠ 400 `no_filter` is a REAL ANSWER, not a failure to handle:
|
| 71 |
+
* a view with no active filter matches every row, so an alert on it would seed
|
| 72 |
+
* with the whole table and could never see an entrant again. The message rides
|
| 73 |
+
* back to the caller verbatim — that is the difference between "refused" and
|
| 74 |
+
* "silently incapable".
|
| 75 |
+
*/
|
| 76 |
+
export function createAlert(
|
| 77 |
+
viewId: string,
|
| 78 |
+
topic: string,
|
| 79 |
+
label?: string
|
| 80 |
+
): Promise<Result<unknown>> {
|
| 81 |
+
return call("/alerts", json({ viewId, topic, ...(label ? { label } : {}) }), (b) => b);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function deleteAlert(id: string): Promise<Result<true>> {
|
| 85 |
+
return call(`/alerts/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/** Run one alert now. Answers `{new:[rowId], seeded}` or `{skipped:"<reason>"}`. */
|
| 89 |
+
export function runAlert(id: string): Promise<Result<unknown>> {
|
| 90 |
+
return call(`/alerts/${encodeURIComponent(id)}/run`, { method: "POST" }, (b) => b);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export { EMPTY_INBOX };
|
web/src/alerts/alertsModel.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// alerts/alertsModel.ts — WAVE 20 item 25 (contract C-ALERT): the inbox's PURE
|
| 3 |
+
// half. React-free and fetch-free, so `verify_alerts.py` runs it under node.
|
| 4 |
+
//
|
| 5 |
+
// An alert says "tell me when a record ENTERS this view". The client half is
|
| 6 |
+
// small, and every part of it fails silently when it is wrong:
|
| 7 |
+
//
|
| 8 |
+
// · an unread count taken from `items.length` rather than from the server's
|
| 9 |
+
// own `unread` disagrees with the badge the moment a page is capped or a
|
| 10 |
+
// read lands in another tab — and a badge that says 3 when the list shows 9
|
| 11 |
+
// teaches the reader to ignore the badge;
|
| 12 |
+
// · a notification whose `viewId` no longer resolves must open NOTHING rather
|
| 13 |
+
// than a wrong view — alerts outlive the views they were made from;
|
| 14 |
+
// · `topic` is the surface's scope key ("customer", "product", "ut_…"), and
|
| 15 |
+
// the ROUTE is a registry key ("customer_data") — mapping one to the other
|
| 16 |
+
// by guesswork sends every click to a page that does not exist.
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
/** One notification, as `GET /api/v1/notifications` sends it. */
|
| 20 |
+
export interface Notification {
|
| 21 |
+
id: string;
|
| 22 |
+
alertId: string;
|
| 23 |
+
viewId: string;
|
| 24 |
+
topic: string;
|
| 25 |
+
rowId: string;
|
| 26 |
+
/** What entered — the record's own label. */
|
| 27 |
+
label: string;
|
| 28 |
+
/** What the alert is called, so a row reads without opening anything. */
|
| 29 |
+
alertLabel: string;
|
| 30 |
+
/** UTC WITH OFFSET (D-18). Kept as the server's STRING: re-formatting it here
|
| 31 |
+
* would re-introduce the browser-clock drift the offset exists to remove. */
|
| 32 |
+
at: string;
|
| 33 |
+
read: boolean;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** One alert, as `GET /api/v1/alerts` sends it. */
|
| 37 |
+
export interface Alert {
|
| 38 |
+
id: string;
|
| 39 |
+
viewId: string;
|
| 40 |
+
topic: string;
|
| 41 |
+
owner: string;
|
| 42 |
+
label: string;
|
| 43 |
+
createdAt: string;
|
| 44 |
+
/** How many records are in its remembered set right now. */
|
| 45 |
+
matched: number;
|
| 46 |
+
seeded: boolean;
|
| 47 |
+
lastRunAt: string;
|
| 48 |
+
lastError: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export interface Inbox {
|
| 52 |
+
unread: number;
|
| 53 |
+
items: Notification[];
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export const EMPTY_INBOX: Inbox = { unread: 0, items: [] };
|
| 57 |
+
|
| 58 |
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 59 |
+
const num = (v: unknown): number => (typeof v === "number" && isFinite(v) ? v : 0);
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* `GET /notifications` → the inbox, fail-closed.
|
| 63 |
+
*
|
| 64 |
+
* ⚠ `unread` COMES FROM THE SERVER, and is not recounted from `items`. The two
|
| 65 |
+
* can legitimately differ — the list is what this page holds, the count is what
|
| 66 |
+
* the account has — and recomputing it here would make the badge a function of
|
| 67 |
+
* whatever the last fetch happened to include.
|
| 68 |
+
*/
|
| 69 |
+
export function parseInbox(body: unknown): Inbox {
|
| 70 |
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
| 71 |
+
const raw = Array.isArray(b.items) ? b.items : [];
|
| 72 |
+
const items: Notification[] = [];
|
| 73 |
+
for (const item of raw) {
|
| 74 |
+
if (!item || typeof item !== "object") continue;
|
| 75 |
+
const n = item as Record<string, unknown>;
|
| 76 |
+
const id = str(n.id);
|
| 77 |
+
// An id-less notification cannot be marked read, so it would sit unread for
|
| 78 |
+
// ever and hold the badge up. Dropped, not rendered.
|
| 79 |
+
if (!id) continue;
|
| 80 |
+
items.push({
|
| 81 |
+
id,
|
| 82 |
+
alertId: str(n.alertId),
|
| 83 |
+
viewId: str(n.viewId),
|
| 84 |
+
topic: str(n.topic),
|
| 85 |
+
rowId: String(n.rowId ?? ""),
|
| 86 |
+
label: str(n.label) || String(n.rowId ?? ""),
|
| 87 |
+
alertLabel: str(n.alertLabel),
|
| 88 |
+
at: str(n.at),
|
| 89 |
+
read: n.read === true,
|
| 90 |
+
});
|
| 91 |
+
}
|
| 92 |
+
return { unread: Math.max(0, num(b.unread)), items };
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/** `GET /alerts` → the alert list, fail-closed. */
|
| 96 |
+
export function parseAlerts(body: unknown): Alert[] {
|
| 97 |
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
| 98 |
+
const raw = Array.isArray(b.alerts) ? b.alerts : [];
|
| 99 |
+
const out: Alert[] = [];
|
| 100 |
+
for (const item of raw) {
|
| 101 |
+
if (!item || typeof item !== "object") continue;
|
| 102 |
+
const a = item as Record<string, unknown>;
|
| 103 |
+
const id = str(a.id);
|
| 104 |
+
if (!id) continue;
|
| 105 |
+
out.push({
|
| 106 |
+
id,
|
| 107 |
+
viewId: str(a.viewId),
|
| 108 |
+
topic: str(a.topic),
|
| 109 |
+
owner: str(a.owner),
|
| 110 |
+
label: str(a.label) || "Untitled alert",
|
| 111 |
+
createdAt: str(a.createdAt),
|
| 112 |
+
matched: num(a.matched),
|
| 113 |
+
seeded: a.seeded === true,
|
| 114 |
+
lastRunAt: str(a.lastRunAt),
|
| 115 |
+
lastError: str(a.lastError),
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
return out;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* A topic (the grid's scope key) → the hash route that renders it.
|
| 123 |
+
*
|
| 124 |
+
* The two built-ins are the only pair that differ, and they differ because the
|
| 125 |
+
* REGISTRY names the surface while the GRID names the scope; a user table is its
|
| 126 |
+
* own key in both. `null` for anything else: a notification for a topic this
|
| 127 |
+
* client cannot route to must do nothing, not navigate somewhere plausible.
|
| 128 |
+
*/
|
| 129 |
+
export function routeForTopic(topic: string): string | null {
|
| 130 |
+
const t = str(topic).trim();
|
| 131 |
+
if (t === "customer") return "customer_data";
|
| 132 |
+
if (t === "product") return "product_data";
|
| 133 |
+
if (/^ut_[A-Za-z0-9_]+$/.test(t)) return t;
|
| 134 |
+
return null;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/** Newest first, and unread before read at the same instant — the inbox order.
|
| 138 |
+
* Stable: two notifications from one write keep the server's order. */
|
| 139 |
+
export function inboxOrder(items: Notification[]): Notification[] {
|
| 140 |
+
return [...items].sort((a, b) => {
|
| 141 |
+
if (a.read !== b.read) return a.read ? 1 : -1;
|
| 142 |
+
return a.at < b.at ? 1 : a.at > b.at ? -1 : 0;
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* The stamp, made readable WITHOUT touching a clock.
|
| 148 |
+
*
|
| 149 |
+
* ⛔ NO `new Date()`, NO `toLocaleString()`, and that is the whole design. The
|
| 150 |
+
* server sends UTC WITH ITS OFFSET (D-18) precisely so every reader sees the
|
| 151 |
+
* same instant; parsing it into a browser Date and formatting it back would
|
| 152 |
+
* re-introduce the drift the offset exists to remove — a tenant a day ahead
|
| 153 |
+
* being told an event happened tomorrow ([[date-window-vocabulary]]). This is
|
| 154 |
+
* STRING SURGERY: keep the date and the minutes, drop the seconds and the `T`.
|
| 155 |
+
* Anything that does not look like an ISO stamp passes through untouched, so a
|
| 156 |
+
* format this function has never seen is shown as sent rather than mangled.
|
| 157 |
+
*/
|
| 158 |
+
export function stampText(at: string): string {
|
| 159 |
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(str(at));
|
| 160 |
+
return m ? `${m[1]} ${m[2]}` : str(at);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* The badge's text. Never the raw number past 99: a nav row is 236px wide and a
|
| 165 |
+
* four-digit badge pushes the label out of it.
|
| 166 |
+
*/
|
| 167 |
+
export function badgeText(unread: number): string {
|
| 168 |
+
const n = Math.max(0, Math.floor(unread));
|
| 169 |
+
if (n <= 0) return "";
|
| 170 |
+
return n > 99 ? "99+" : String(n);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Apply a read/unread change LOCALLY, mirroring what the server just stored, and
|
| 175 |
+
* return the new inbox with the count corrected.
|
| 176 |
+
*
|
| 177 |
+
* `ids === null` is "all of them" (the API's own convention for mark-all). The
|
| 178 |
+
* count is derived from the ITEMS here — deliberately, and it is the one place
|
| 179 |
+
* that is right to do so: the server's answer is in flight, and the alternative
|
| 180 |
+
* is a badge that keeps its old number until the refetch lands.
|
| 181 |
+
*/
|
| 182 |
+
export function applyRead(inbox: Inbox, ids: string[] | null, read: boolean): Inbox {
|
| 183 |
+
const wanted = ids === null ? null : new Set(ids);
|
| 184 |
+
const items = inbox.items.map((n) =>
|
| 185 |
+
wanted === null || wanted.has(n.id) ? { ...n, read } : n
|
| 186 |
+
);
|
| 187 |
+
const seenUnread = items.filter((n) => !n.read).length;
|
| 188 |
+
// A page can hold fewer notifications than the account has, so a partial read
|
| 189 |
+
// must SUBTRACT from the server's count rather than replace it with this
|
| 190 |
+
// page's tally — except when marking everything, where zero is the answer.
|
| 191 |
+
if (wanted === null) return { unread: read ? 0 : items.length, items };
|
| 192 |
+
const changed = inbox.items.filter(
|
| 193 |
+
(n) => wanted.has(n.id) && n.read !== read
|
| 194 |
+
).length;
|
| 195 |
+
const delta = read ? -changed : changed;
|
| 196 |
+
return { unread: Math.max(seenUnread, inbox.unread + delta), items };
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* The shell↔rail channel for "make an alert out of this view" — the view rail
|
| 201 |
+
* raises it, the frame (which knows the current route, and therefore the topic)
|
| 202 |
+
* answers. Same reason as C-SHARE's event: the rail is host-neutral and cannot
|
| 203 |
+
* import the shell, and it does not know its own scope key.
|
| 204 |
+
*/
|
| 205 |
+
export const ALERT_CREATE_EVENT = "aios:alert-create";
|
| 206 |
+
|
| 207 |
+
export interface AlertCreateRequest {
|
| 208 |
+
viewId: string;
|
| 209 |
+
label: string;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
export function parseAlertCreate(detail: unknown): AlertCreateRequest | null {
|
| 213 |
+
if (!detail || typeof detail !== "object") return null;
|
| 214 |
+
const d = detail as Record<string, unknown>;
|
| 215 |
+
const viewId = str(d.viewId).trim();
|
| 216 |
+
if (!viewId) return null;
|
| 217 |
+
return { viewId, label: str(d.label).trim() || viewId };
|
| 218 |
+
}
|
web/src/automation/AutomationCreate.tsx
CHANGED
|
@@ -104,7 +104,19 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 104 |
config:
|
| 105 |
kind === "scrape_db"
|
| 106 |
? { url, extract, tableIndex, fieldMap, keyField, targetLabel }
|
| 107 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
});
|
| 109 |
await onCreated(res.automation.id);
|
| 110 |
} catch (e) {
|
|
@@ -118,7 +130,9 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 118 |
!!name.trim() &&
|
| 119 |
(kind === "scrape_db"
|
| 120 |
? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
|
| 121 |
-
:
|
|
|
|
|
|
|
| 122 |
|
| 123 |
return (
|
| 124 |
<div className="auto-create-pane">
|
|
@@ -164,7 +178,21 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 164 |
))}
|
| 165 |
</div>
|
| 166 |
|
| 167 |
-
{kind === "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
<>
|
| 169 |
<div className="auto-field">
|
| 170 |
<label htmlFor="auto-new-url">Page URL</label>
|
|
@@ -363,8 +391,11 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 363 |
</div>
|
| 364 |
</div>
|
| 365 |
<p className="auto-hint">
|
| 366 |
-
It starts on the free anonymous rung. The canvas’s
|
| 367 |
-
paid one, which returns exact counts and
|
|
|
|
|
|
|
|
|
|
| 368 |
</p>
|
| 369 |
{table && !(table.fields || []).some((f) => f.type === "automation") ? (
|
| 370 |
<p className="auto-note">
|
|
|
|
| 104 |
config:
|
| 105 |
kind === "scrape_db"
|
| 106 |
? { url, extract, tableIndex, fieldMap, keyField, targetLabel }
|
| 107 |
+
: kind === "discover_instagram"
|
| 108 |
+
? {
|
| 109 |
+
// A search is created with a SMALL, NARROW default and no schedule, then
|
| 110 |
+
// widened on the canvas where the cost estimate is. The one shape that must
|
| 111 |
+
// never be the default is the expensive one.
|
| 112 |
+
recordsLimit: 25,
|
| 113 |
+
operator: "and",
|
| 114 |
+
predicates: [
|
| 115 |
+
{ name: "followers", operator: ">=", value: 10000 },
|
| 116 |
+
{ name: "biography", operator: "includes", value: "" },
|
| 117 |
+
],
|
| 118 |
+
}
|
| 119 |
+
: { targetTable, fieldKey, urlField, maxPosts: 24, tier: "anonymous" },
|
| 120 |
});
|
| 121 |
await onCreated(res.automation.id);
|
| 122 |
} catch (e) {
|
|
|
|
| 130 |
!!name.trim() &&
|
| 131 |
(kind === "scrape_db"
|
| 132 |
? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
|
| 133 |
+
: kind === "discover_instagram"
|
| 134 |
+
? true
|
| 135 |
+
: !!targetTable && !!fieldKey);
|
| 136 |
|
| 137 |
return (
|
| 138 |
<div className="auto-create-pane">
|
|
|
|
| 178 |
))}
|
| 179 |
</div>
|
| 180 |
|
| 181 |
+
{kind === "discover_instagram" ? (
|
| 182 |
+
<>
|
| 183 |
+
<p className="auto-hint">
|
| 184 |
+
It searches a corpus of pre-collected public Instagram profiles for accounts you
|
| 185 |
+
have never heard of, and collects the matches into a candidates database. You set
|
| 186 |
+
the conditions and how many profiles to fetch on the canvas, where the cost
|
| 187 |
+
estimate is — searching is the one thing this workspace does that spends money per
|
| 188 |
+
run.
|
| 189 |
+
</p>
|
| 190 |
+
<p className="auto-hint">
|
| 191 |
+
It starts with a narrow default and no schedule. Nothing it finds is ever tracked
|
| 192 |
+
automatically; you tick the accounts you want.
|
| 193 |
+
</p>
|
| 194 |
+
</>
|
| 195 |
+
) : kind === "scrape_db" ? (
|
| 196 |
<>
|
| 197 |
<div className="auto-field">
|
| 198 |
<label htmlFor="auto-new-url">Page URL</label>
|
|
|
|
| 391 |
</div>
|
| 392 |
</div>
|
| 393 |
<p className="auto-hint">
|
| 394 |
+
It starts on the free anonymous rung. The canvas’s Bright Data step turns on
|
| 395 |
+
the paid one, which returns exact counts and the profile’s top posts, and
|
| 396 |
+
works from a datacenter. Likes and comments per post are a separate step again —
|
| 397 |
+
they cost one record per post rather than one per profile, so they are off until
|
| 398 |
+
you turn them on.
|
| 399 |
</p>
|
| 400 |
{table && !(table.fields || []).some((f) => f.type === "automation") ? (
|
| 401 |
<p className="auto-note">
|
web/src/automation/AutomationDetail.tsx
CHANGED
|
@@ -27,6 +27,8 @@ import type {
|
|
| 27 |
GraphNode,
|
| 28 |
RunEntry,
|
| 29 |
RunRows,
|
|
|
|
|
|
|
| 30 |
SourcePreview,
|
| 31 |
UserTable,
|
| 32 |
} from "./automationApi";
|
|
@@ -34,6 +36,7 @@ import {
|
|
| 34 |
AutomationError,
|
| 35 |
COUNT_LABELS,
|
| 36 |
deleteAutomation,
|
|
|
|
| 37 |
fieldKeyFor,
|
| 38 |
listTables,
|
| 39 |
patchAutomation,
|
|
@@ -47,7 +50,9 @@ interface Props {
|
|
| 47 |
automation: Automation;
|
| 48 |
kinds: { key: AutomationKind; label: string }[];
|
| 49 |
cronPresets: { cron: string; label: string }[];
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
onSaved: (id?: string) => void | Promise<void>;
|
| 52 |
onDeleted: () => void | Promise<void>;
|
| 53 |
}
|
|
@@ -58,11 +63,19 @@ interface ColumnPlan {
|
|
| 58 |
key: string;
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
export default function AutomationDetail({
|
| 62 |
automation,
|
| 63 |
kinds,
|
| 64 |
cronPresets,
|
| 65 |
-
|
|
|
|
| 66 |
onSaved,
|
| 67 |
onDeleted,
|
| 68 |
}: Props) {
|
|
@@ -98,6 +111,17 @@ export default function AutomationDetail({
|
|
| 98 |
const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
|
| 99 |
const [maxPosts, setMaxPosts] = useState(Number(cfg.maxPosts || 24));
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
// --- run history drill
|
| 102 |
const [drill, setDrill] = useState<RunRows | null>(null);
|
| 103 |
const [drillFor, setDrillFor] = useState("");
|
|
@@ -179,6 +203,14 @@ export default function AutomationDetail({
|
|
| 179 |
targetLabel,
|
| 180 |
};
|
| 181 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
return { targetTable, fieldKey, urlField, maxPosts };
|
| 183 |
};
|
| 184 |
|
|
@@ -545,6 +577,9 @@ export default function AutomationDetail({
|
|
| 545 |
}}
|
| 546 |
>
|
| 547 |
<option value="">Choose a database…</option>
|
|
|
|
|
|
|
|
|
|
| 548 |
{tables.map((t) => (
|
| 549 |
<option key={t.key} value={t.key}>
|
| 550 |
{t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
|
|
@@ -577,6 +612,18 @@ export default function AutomationDetail({
|
|
| 577 |
onChange={(e) => edit(setFieldKey)(e.target.value)}
|
| 578 |
>
|
| 579 |
<option value="">Choose a column…</option>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
{(table?.fields || [])
|
| 581 |
.filter((f) => f.type === "automation")
|
| 582 |
.map((f) => (
|
|
@@ -609,14 +656,22 @@ export default function AutomationDetail({
|
|
| 609 |
onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 24)}
|
| 610 |
/>
|
| 611 |
</div>
|
| 612 |
-
<h3>
|
| 613 |
<p className="auto-hint">
|
| 614 |
-
Exact follower counts and
|
| 615 |
-
|
| 616 |
-
{
|
| 617 |
? " A key is configured on this deployment."
|
| 618 |
: " No key is configured on this deployment yet, so it will report itself blocked and the anonymous rung will answer instead."}
|
| 619 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
<h3>Anonymous — the free rung</h3>
|
| 621 |
<p className="auto-hint">
|
| 622 |
No login and no credentials. Instagram blocks datacenter addresses and has
|
|
@@ -634,6 +689,181 @@ export default function AutomationDetail({
|
|
| 634 |
</>
|
| 635 |
) : null}
|
| 636 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
{node.panel === "write" ? (
|
| 638 |
<>
|
| 639 |
{kind === "scrape_db" ? (
|
|
@@ -647,11 +877,20 @@ export default function AutomationDetail({
|
|
| 647 |
onChange={(e) => edit(setTargetLabel)(e.target.value)}
|
| 648 |
/>
|
| 649 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
) : (
|
| 651 |
<p className="auto-hint">
|
| 652 |
-
Each pull appends a timestamped row to <code>ut_ig_snapshots</code> and,
|
| 653 |
-
|
| 654 |
-
|
|
|
|
| 655 |
<code>ut_ig_posts</code> holds one row per post, ever.
|
| 656 |
</p>
|
| 657 |
)}
|
|
@@ -660,6 +899,20 @@ export default function AutomationDetail({
|
|
| 660 |
reads everything and reports exactly what it would have written, without
|
| 661 |
creating or changing a single row.
|
| 662 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
<p className="auto-hint">
|
| 664 |
A row that stops appearing at the source is counted and kept, never deleted.
|
| 665 |
</p>
|
|
|
|
| 27 |
GraphNode,
|
| 28 |
RunEntry,
|
| 29 |
RunRows,
|
| 30 |
+
DiscoverEstimate,
|
| 31 |
+
DiscoverVocab,
|
| 32 |
SourcePreview,
|
| 33 |
UserTable,
|
| 34 |
} from "./automationApi";
|
|
|
|
| 36 |
AutomationError,
|
| 37 |
COUNT_LABELS,
|
| 38 |
deleteAutomation,
|
| 39 |
+
discoverEstimate,
|
| 40 |
fieldKeyFor,
|
| 41 |
listTables,
|
| 42 |
patchAutomation,
|
|
|
|
| 50 |
automation: Automation;
|
| 51 |
kinds: { key: AutomationKind; label: string }[];
|
| 52 |
cronPresets: { cron: string; label: string }[];
|
| 53 |
+
paidReady: boolean;
|
| 54 |
+
/** The server-declared discovery vocabulary (absent until the list loads). */
|
| 55 |
+
discover?: DiscoverVocab;
|
| 56 |
onSaved: (id?: string) => void | Promise<void>;
|
| 57 |
onDeleted: () => void | Promise<void>;
|
| 58 |
}
|
|
|
|
| 63 |
key: string;
|
| 64 |
}
|
| 65 |
|
| 66 |
+
/** One condition of a corpus search. Mirrors the server's `clean_predicates`. */
|
| 67 |
+
interface Predicate {
|
| 68 |
+
name: string;
|
| 69 |
+
operator: string;
|
| 70 |
+
value?: string | number;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
export default function AutomationDetail({
|
| 74 |
automation,
|
| 75 |
kinds,
|
| 76 |
cronPresets,
|
| 77 |
+
paidReady,
|
| 78 |
+
discover,
|
| 79 |
onSaved,
|
| 80 |
onDeleted,
|
| 81 |
}: Props) {
|
|
|
|
| 111 |
const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
|
| 112 |
const [maxPosts, setMaxPosts] = useState(Number(cfg.maxPosts || 24));
|
| 113 |
|
| 114 |
+
// --- discover_instagram half (R7)
|
| 115 |
+
const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
|
| 116 |
+
const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
|
| 117 |
+
const [preds, setPreds] = useState<Predicate[]>(() => {
|
| 118 |
+
const stored = (automation.config as { predicates?: Predicate[] })?.predicates;
|
| 119 |
+
return stored && stored.length
|
| 120 |
+
? stored.map((p) => ({ ...p }))
|
| 121 |
+
: [{ name: "followers", operator: ">=", value: 10000 }];
|
| 122 |
+
});
|
| 123 |
+
const [estimate, setEstimate] = useState<DiscoverEstimate | null>(null);
|
| 124 |
+
|
| 125 |
// --- run history drill
|
| 126 |
const [drill, setDrill] = useState<RunRows | null>(null);
|
| 127 |
const [drillFor, setDrillFor] = useState("");
|
|
|
|
| 203 |
targetLabel,
|
| 204 |
};
|
| 205 |
}
|
| 206 |
+
if (kind === "discover_instagram") {
|
| 207 |
+
return {
|
| 208 |
+
recordsLimit,
|
| 209 |
+
operator: joinOp,
|
| 210 |
+
predicates: preds.filter((p) => p.name && p.operator),
|
| 211 |
+
targetTable: String(cfg.targetTable || ""),
|
| 212 |
+
};
|
| 213 |
+
}
|
| 214 |
return { targetTable, fieldKey, urlField, maxPosts };
|
| 215 |
};
|
| 216 |
|
|
|
|
| 577 |
}}
|
| 578 |
>
|
| 579 |
<option value="">Choose a database…</option>
|
| 580 |
+
{targetTable && !tables.some((t) => t.key === targetTable) ? (
|
| 581 |
+
<option value={targetTable}>{targetTable} (not visible to you)</option>
|
| 582 |
+
) : null}
|
| 583 |
{tables.map((t) => (
|
| 584 |
<option key={t.key} value={t.key}>
|
| 585 |
{t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
|
|
|
|
| 612 |
onChange={(e) => edit(setFieldKey)(e.target.value)}
|
| 613 |
>
|
| 614 |
<option value="">Choose a column…</option>
|
| 615 |
+
{/*
|
| 616 |
+
⚠ THE STORED VALUE IS ALWAYS AN OPTION, even when the list
|
| 617 |
+
below does not contain it. A <select> whose `value` matches
|
| 618 |
+
no <option> renders the FIRST one — so a column the server
|
| 619 |
+
knows about but this list has not caught up with would look
|
| 620 |
+
like a DIFFERENT column, and the next Save would write that
|
| 621 |
+
different column without anybody choosing it. Showing it as
|
| 622 |
+
"(not in this database)" is the honest version.
|
| 623 |
+
*/}
|
| 624 |
+
{fieldKey && !(table?.fields || []).some((f) => f.key === fieldKey) ? (
|
| 625 |
+
<option value={fieldKey}>{fieldKey} (not in this database)</option>
|
| 626 |
+
) : null}
|
| 627 |
{(table?.fields || [])
|
| 628 |
.filter((f) => f.type === "automation")
|
| 629 |
.map((f) => (
|
|
|
|
| 656 |
onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 24)}
|
| 657 |
/>
|
| 658 |
</div>
|
| 659 |
+
<h3>Bright Data — the paid rung</h3>
|
| 660 |
<p className="auto-hint">
|
| 661 |
+
Exact follower counts and the profile’s top posts, and it works from a
|
| 662 |
+
datacenter. Its node’s switch turns it on.
|
| 663 |
+
{paidReady
|
| 664 |
? " A key is configured on this deployment."
|
| 665 |
: " No key is configured on this deployment yet, so it will report itself blocked and the anonymous rung will answer instead."}
|
| 666 |
</p>
|
| 667 |
+
<h3>Post engagement — the per-post rung</h3>
|
| 668 |
+
<p className="auto-hint">
|
| 669 |
+
Likes and comments for each post. It is a separate step because it is a
|
| 670 |
+
separate bill: the profile read costs one record, and engagement costs{" "}
|
| 671 |
+
<strong>one record per post</strong>. With it off, the posts database still
|
| 672 |
+
grows but the engagement series does not — and no empty rows are appended to
|
| 673 |
+
stand in for the numbers nobody bought.
|
| 674 |
+
</p>
|
| 675 |
<h3>Anonymous — the free rung</h3>
|
| 676 |
<p className="auto-hint">
|
| 677 |
No login and no credentials. Instagram blocks datacenter addresses and has
|
|
|
|
| 689 |
</>
|
| 690 |
) : null}
|
| 691 |
|
| 692 |
+
{node.panel === "find" ? (
|
| 693 |
+
<>
|
| 694 |
+
<div className="auto-field">
|
| 695 |
+
<label htmlFor="auto-records">How many profiles to fetch</label>
|
| 696 |
+
<input
|
| 697 |
+
id="auto-records"
|
| 698 |
+
className="auto-input"
|
| 699 |
+
type="number"
|
| 700 |
+
min={1}
|
| 701 |
+
max={discover?.maxRecords || 1000}
|
| 702 |
+
value={recordsLimit}
|
| 703 |
+
onChange={(e) => {
|
| 704 |
+
edit(setRecordsLimit)(Number(e.target.value) || 1);
|
| 705 |
+
setEstimate(null);
|
| 706 |
+
}}
|
| 707 |
+
/>
|
| 708 |
+
</div>
|
| 709 |
+
<p className="auto-hint">
|
| 710 |
+
A search must be bounded. An unbounded one matches a share of a corpus of
|
| 711 |
+
hundreds of millions of profiles, and the vendor refuses it outright rather
|
| 712 |
+
than delivering it.
|
| 713 |
+
</p>
|
| 714 |
+
|
| 715 |
+
<h3>Conditions</h3>
|
| 716 |
+
{preds.map((pr, i) => (
|
| 717 |
+
<div className="auto-field-row" key={i}>
|
| 718 |
+
<div className="auto-field">
|
| 719 |
+
<label htmlFor={"auto-pred-f-" + i}>Field</label>
|
| 720 |
+
<select
|
| 721 |
+
id={"auto-pred-f-" + i}
|
| 722 |
+
className="auto-input"
|
| 723 |
+
value={pr.name}
|
| 724 |
+
onChange={(e) =>
|
| 725 |
+
edit(setPreds)(
|
| 726 |
+
preds.map((q, j) => (j === i ? { ...q, name: e.target.value } : q))
|
| 727 |
+
)
|
| 728 |
+
}
|
| 729 |
+
>
|
| 730 |
+
{/*
|
| 731 |
+
The stored value is ALWAYS an option — see the note on the
|
| 732 |
+
automation-column picker. A select whose value matches no option
|
| 733 |
+
renders the first one, which silently changes what a Save writes.
|
| 734 |
+
*/}
|
| 735 |
+
{pr.name && !(discover?.fields || []).includes(pr.name) ? (
|
| 736 |
+
<option value={pr.name}>{pr.name}</option>
|
| 737 |
+
) : null}
|
| 738 |
+
{(discover?.fields || []).map((f) => (
|
| 739 |
+
<option key={f} value={f}>
|
| 740 |
+
{f}
|
| 741 |
+
{(discover?.lead || []).includes(f) ? " (has values)" : ""}
|
| 742 |
+
</option>
|
| 743 |
+
))}
|
| 744 |
+
</select>
|
| 745 |
+
</div>
|
| 746 |
+
<div className="auto-field">
|
| 747 |
+
<label htmlFor={"auto-pred-o-" + i}>Comparison</label>
|
| 748 |
+
<select
|
| 749 |
+
id={"auto-pred-o-" + i}
|
| 750 |
+
className="auto-input"
|
| 751 |
+
value={pr.operator}
|
| 752 |
+
onChange={(e) =>
|
| 753 |
+
edit(setPreds)(
|
| 754 |
+
preds.map((q, j) =>
|
| 755 |
+
j === i ? { ...q, operator: e.target.value } : q
|
| 756 |
+
)
|
| 757 |
+
)
|
| 758 |
+
}
|
| 759 |
+
>
|
| 760 |
+
{pr.operator && !(discover?.operators || []).includes(pr.operator) ? (
|
| 761 |
+
<option value={pr.operator}>{pr.operator}</option>
|
| 762 |
+
) : null}
|
| 763 |
+
{(discover?.operators || []).map((o) => (
|
| 764 |
+
<option key={o} value={o}>
|
| 765 |
+
{o}
|
| 766 |
+
</option>
|
| 767 |
+
))}
|
| 768 |
+
</select>
|
| 769 |
+
</div>
|
| 770 |
+
{(discover?.nullaryOperators || []).includes(pr.operator) ? null : (
|
| 771 |
+
<div className="auto-field">
|
| 772 |
+
<label htmlFor={"auto-pred-v-" + i}>Value</label>
|
| 773 |
+
<input
|
| 774 |
+
id={"auto-pred-v-" + i}
|
| 775 |
+
className="auto-input"
|
| 776 |
+
value={pr.value === undefined ? "" : String(pr.value)}
|
| 777 |
+
onChange={(e) =>
|
| 778 |
+
edit(setPreds)(
|
| 779 |
+
preds.map((q, j) => (j === i ? { ...q, value: e.target.value } : q))
|
| 780 |
+
)
|
| 781 |
+
}
|
| 782 |
+
/>
|
| 783 |
+
</div>
|
| 784 |
+
)}
|
| 785 |
+
<div className="auto-field">
|
| 786 |
+
<label htmlFor={"auto-pred-x-" + i}> </label>
|
| 787 |
+
<button
|
| 788 |
+
id={"auto-pred-x-" + i}
|
| 789 |
+
type="button"
|
| 790 |
+
className="auto-btn"
|
| 791 |
+
disabled={preds.length < 2}
|
| 792 |
+
onClick={() => edit(setPreds)(preds.filter((_q, j) => j !== i))}
|
| 793 |
+
>
|
| 794 |
+
Remove
|
| 795 |
+
</button>
|
| 796 |
+
</div>
|
| 797 |
+
</div>
|
| 798 |
+
))}
|
| 799 |
+
<div className="auto-head-actions">
|
| 800 |
+
<button
|
| 801 |
+
type="button"
|
| 802 |
+
className="auto-btn"
|
| 803 |
+
onClick={() =>
|
| 804 |
+
edit(setPreds)([
|
| 805 |
+
...preds,
|
| 806 |
+
{ name: "biography", operator: "includes", value: "" },
|
| 807 |
+
])
|
| 808 |
+
}
|
| 809 |
+
>
|
| 810 |
+
Add a condition
|
| 811 |
+
</button>
|
| 812 |
+
<div className="auto-field">
|
| 813 |
+
<label htmlFor="auto-joinop">Match</label>
|
| 814 |
+
<select
|
| 815 |
+
id="auto-joinop"
|
| 816 |
+
className="auto-input"
|
| 817 |
+
value={joinOp}
|
| 818 |
+
onChange={(e) => edit(setJoinOp)(e.target.value)}
|
| 819 |
+
>
|
| 820 |
+
<option value="and">All of them</option>
|
| 821 |
+
<option value="or">Any of them</option>
|
| 822 |
+
</select>
|
| 823 |
+
</div>
|
| 824 |
+
</div>
|
| 825 |
+
<p className="auto-hint">
|
| 826 |
+
Fields marked <em>has values</em> are the ones we have actually seen carrying
|
| 827 |
+
data. The others can be searched, but were empty on every profile we have
|
| 828 |
+
looked at — and a condition on an empty field returns nothing while looking
|
| 829 |
+
exactly like “no such accounts exist”.
|
| 830 |
+
</p>
|
| 831 |
+
<p className="auto-hint">
|
| 832 |
+
Narrow conditions matter more than a small limit. The search reads the whole
|
| 833 |
+
corpus either way, so a broad condition is slow and expensive however few rows
|
| 834 |
+
you ask to keep. A search normally takes about twenty minutes; a run that
|
| 835 |
+
finds one still going says so and the next run collects it.
|
| 836 |
+
</p>
|
| 837 |
+
|
| 838 |
+
<h3>What it costs</h3>
|
| 839 |
+
<div className="auto-head-actions">
|
| 840 |
+
<button
|
| 841 |
+
type="button"
|
| 842 |
+
className="auto-btn"
|
| 843 |
+
onClick={() => {
|
| 844 |
+
void discoverEstimate(recordsLimit)
|
| 845 |
+
.then(setEstimate)
|
| 846 |
+
.catch(() => setEstimate(null));
|
| 847 |
+
}}
|
| 848 |
+
>
|
| 849 |
+
Estimate this search
|
| 850 |
+
</button>
|
| 851 |
+
</div>
|
| 852 |
+
{estimate ? (
|
| 853 |
+
<p className="auto-hint">
|
| 854 |
+
About <strong>${estimate.usd}</strong> for {estimate.records} profiles, at $
|
| 855 |
+
{estimate.unitUsd} each.{" "}
|
| 856 |
+
{/*
|
| 857 |
+
THE CAVEAT IS NOT OPTIONAL. The vendor never quotes a price before a run
|
| 858 |
+
and this deployment cannot read its own balance, so presenting this as a
|
| 859 |
+
billed figure would be inventing a measurement.
|
| 860 |
+
*/}
|
| 861 |
+
<strong>This is an estimate</strong> — {estimate.note}.
|
| 862 |
+
</p>
|
| 863 |
+
) : null}
|
| 864 |
+
</>
|
| 865 |
+
) : null}
|
| 866 |
+
|
| 867 |
{node.panel === "write" ? (
|
| 868 |
<>
|
| 869 |
{kind === "scrape_db" ? (
|
|
|
|
| 877 |
onChange={(e) => edit(setTargetLabel)(e.target.value)}
|
| 878 |
/>
|
| 879 |
</div>
|
| 880 |
+
) : kind === "discover_instagram" ? (
|
| 881 |
+
<p className="auto-hint">
|
| 882 |
+
Matches are collected into{" "}
|
| 883 |
+
<code>{discover?.table || "ut_ig_candidates"}</code>, upserted by handle — so
|
| 884 |
+
re-finding an account costs nothing and its <em>Times found</em> count grows.{" "}
|
| 885 |
+
<strong>Nothing found is ever tracked automatically.</strong> Tick the ones
|
| 886 |
+
you want; a later search can never un-tick them.
|
| 887 |
+
</p>
|
| 888 |
) : (
|
| 889 |
<p className="auto-hint">
|
| 890 |
+
Each pull appends a timestamped row to <code>ut_ig_snapshots</code> and, when
|
| 891 |
+
the post-engagement step is on, one per post to{" "}
|
| 892 |
+
<code>ut_ig_post_snapshots</code> — so the history is a time series you can
|
| 893 |
+
filter by date rather than a single current value.{" "}
|
| 894 |
<code>ut_ig_posts</code> holds one row per post, ever.
|
| 895 |
</p>
|
| 896 |
)}
|
|
|
|
| 899 |
reads everything and reports exactly what it would have written, without
|
| 900 |
creating or changing a single row.
|
| 901 |
</p>
|
| 902 |
+
{kind === "field_instagram" ? (
|
| 903 |
+
<p className="auto-hint">
|
| 904 |
+
{/*
|
| 905 |
+
⚠ "Writes nothing" and "costs nothing" are DIFFERENT CLAIMS, and only the
|
| 906 |
+
first one is true. A dry run still READS — so with the paid steps on it
|
| 907 |
+
bills exactly what a real run bills. Saying so here is the only place a
|
| 908 |
+
user finds out before the invoice.
|
| 909 |
+
*/}
|
| 910 |
+
A dry run still <strong>reads</strong>. With the Bright Data step on it
|
| 911 |
+
costs what a real run costs, and with post engagement on that is one
|
| 912 |
+
charged record per post. Turn those steps off too if you want a free
|
| 913 |
+
rehearsal.
|
| 914 |
+
</p>
|
| 915 |
+
) : null}
|
| 916 |
<p className="auto-hint">
|
| 917 |
A row that stops appearing at the source is counted and kept, never deleted.
|
| 918 |
</p>
|
web/src/automation/AutomationSurface.tsx
CHANGED
|
@@ -257,7 +257,8 @@ export default function AutomationSurface() {
|
|
| 257 |
automation={active}
|
| 258 |
kinds={data?.kinds || []}
|
| 259 |
cronPresets={data?.cronPresets || []}
|
| 260 |
-
|
|
|
|
| 261 |
onSaved={async (id) => {
|
| 262 |
const next = await load();
|
| 263 |
if (id && next) setActiveId(id);
|
|
@@ -284,10 +285,18 @@ export default function AutomationSurface() {
|
|
| 284 |
</li>
|
| 285 |
<li>
|
| 286 |
<strong>Instagram profile column.</strong> For every record carrying a
|
| 287 |
-
public profile URL, read the profile — exactly, through
|
| 288 |
approximately on the free anonymous rung — and append a timestamped row per
|
| 289 |
-
pull, so followers
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
</li>
|
| 292 |
</ul>
|
| 293 |
</div>
|
|
|
|
| 257 |
automation={active}
|
| 258 |
kinds={data?.kinds || []}
|
| 259 |
cronPresets={data?.cronPresets || []}
|
| 260 |
+
paidReady={!!data?.paidReady}
|
| 261 |
+
discover={data?.discover}
|
| 262 |
onSaved={async (id) => {
|
| 263 |
const next = await load();
|
| 264 |
if (id && next) setActiveId(id);
|
|
|
|
| 285 |
</li>
|
| 286 |
<li>
|
| 287 |
<strong>Instagram profile column.</strong> For every record carrying a
|
| 288 |
+
public profile URL, read the profile — exactly, through Bright Data, or
|
| 289 |
approximately on the free anonymous rung — and append a timestamped row per
|
| 290 |
+
pull, so followers become a series you can filter by date rather than a
|
| 291 |
+
single current number.
|
| 292 |
+
</li>
|
| 293 |
+
<li>
|
| 294 |
+
<strong>Find Instagram profiles.</strong> Search a corpus of pre-collected
|
| 295 |
+
public profiles for accounts you have never heard of — a follower band, a
|
| 296 |
+
phrase in the bio, an engagement floor — and collect the matches into a
|
| 297 |
+
candidates database. Searching costs money, so every run is bounded by a
|
| 298 |
+
profile count you set and shows an estimate first. Nothing found is ever
|
| 299 |
+
tracked automatically; you tick the ones you want.
|
| 300 |
</li>
|
| 301 |
</ul>
|
| 302 |
</div>
|
web/src/automation/automationApi.ts
CHANGED
|
@@ -16,9 +16,14 @@
|
|
| 16 |
// ---------------------------------------------------------------------------
|
| 17 |
import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, signal } from "../apiContract";
|
| 18 |
|
| 19 |
-
export type AutomationKind = "scrape_db" | "field_instagram";
|
| 20 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
/**
|
| 24 |
* A node's dot. Deliberately WIDER than `RunState` — a single step can be
|
|
@@ -92,14 +97,60 @@ export interface AutomationList {
|
|
| 92 |
kinds: { key: AutomationKind; label: string }[];
|
| 93 |
cronPresets: { cron: string; label: string }[];
|
| 94 |
/**
|
| 95 |
-
* Is `
|
| 96 |
* the surface needs to say "the paid rung is not configured" honestly rather
|
| 97 |
* than offering a tier that will silently refuse.
|
| 98 |
*/
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
storeAvailable: boolean;
|
| 101 |
}
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
export interface UserTable {
|
| 104 |
key: string;
|
| 105 |
label: string;
|
|
@@ -198,9 +249,10 @@ export function runAutomation(id: string): Promise<{ started: string }> {
|
|
| 198 |
* Flip one canvas node's switch.
|
| 199 |
*
|
| 200 |
* The client says WHICH NODE was clicked and nothing else — the server owns what
|
| 201 |
-
* each switch means (`engine.NODE_TOGGLES`). A client that knew "the
|
| 202 |
* node writes config.tier" would be a second copy of that knowledge, free to
|
| 203 |
-
* drift from the engine that reads it
|
|
|
|
| 204 |
*/
|
| 205 |
export function toggleNode(
|
| 206 |
id: string,
|
|
@@ -220,6 +272,14 @@ export function runRows(id: string): Promise<RunRows> {
|
|
| 220 |
return send(`/automations/${encodeURIComponent(id)}/rows`);
|
| 221 |
}
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
export function previewSource(
|
| 224 |
url: string,
|
| 225 |
extract: string,
|
|
@@ -278,7 +338,20 @@ export const COUNT_LABELS: [string, string][] = [
|
|
| 278 |
["partial", "Profile only"],
|
| 279 |
["blocked", "Blocked"],
|
| 280 |
["error", "Errors"],
|
| 281 |
-
["paid", "Exact via
|
| 282 |
["posts", "Posts"],
|
| 283 |
["new_posts", "New posts"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
];
|
|
|
|
| 16 |
// ---------------------------------------------------------------------------
|
| 17 |
import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, signal } from "../apiContract";
|
| 18 |
|
| 19 |
+
export type AutomationKind = "scrape_db" | "field_instagram" | "discover_instagram";
|
| 20 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 21 |
+
/**
|
| 22 |
+
* ⚠ `hiker` is NOT in this union any more (wave 20, D-21) — the vendor moved.
|
| 23 |
+
* Stored configs still SAY `hiker` and the server maps them forward
|
| 24 |
+
* (`TIER_ALIASES`), so nothing in this client should ever write that word.
|
| 25 |
+
*/
|
| 26 |
+
export type CaptureTier = "anonymous" | "brightdata";
|
| 27 |
|
| 28 |
/**
|
| 29 |
* A node's dot. Deliberately WIDER than `RunState` — a single step can be
|
|
|
|
| 97 |
kinds: { key: AutomationKind; label: string }[];
|
| 98 |
cronPresets: { cron: string; label: string }[];
|
| 99 |
/**
|
| 100 |
+
* Is `AIOS_BRIGHTDATA_KEY` configured on the server? A BOOLEAN, never the key —
|
| 101 |
* the surface needs to say "the paid rung is not configured" honestly rather
|
| 102 |
* than offering a tier that will silently refuse.
|
| 103 |
*/
|
| 104 |
+
paidReady: boolean;
|
| 105 |
+
/**
|
| 106 |
+
* THE SOURCE REGISTRY (DEBT D-9's seam). Server-declared, for the reason
|
| 107 |
+
* `cronPresets` is: the module that RUNS a source is the only thing entitled
|
| 108 |
+
* to say what it can do, and a client copy of "Instagram can discover,
|
| 109 |
+
* TikTok cannot" goes stale in silence. `canDiscover: false` is an honest
|
| 110 |
+
* statement, not a gap — offering the button anyway offers a refusal.
|
| 111 |
+
*/
|
| 112 |
+
sources: SourceStatus[];
|
| 113 |
+
/** The discovery vocabulary — every name MEASURED-accepted by the vendor. */
|
| 114 |
+
discover: DiscoverVocab;
|
| 115 |
storeAvailable: boolean;
|
| 116 |
}
|
| 117 |
|
| 118 |
+
export interface SourceStatus {
|
| 119 |
+
key: string;
|
| 120 |
+
label: string;
|
| 121 |
+
vendor: string;
|
| 122 |
+
ready: boolean;
|
| 123 |
+
canDiscover: boolean;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export interface DiscoverVocab {
|
| 127 |
+
fields: string[];
|
| 128 |
+
/**
|
| 129 |
+
* The subset seen carrying VALUES on real corpus rows. The others are
|
| 130 |
+
* filterable and were null on every row measured — and a filter on an
|
| 131 |
+
* unpopulated field returns nothing while looking exactly like "no such
|
| 132 |
+
* influencers exist".
|
| 133 |
+
*/
|
| 134 |
+
lead: string[];
|
| 135 |
+
operators: string[];
|
| 136 |
+
nullaryOperators: string[];
|
| 137 |
+
maxRecords: number;
|
| 138 |
+
table: string;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* ⚠ AN ESTIMATE, AND IT CARRIES ITS OWN CAVEAT (`basis: "SPEC"`). Bright Data
|
| 143 |
+
* never quotes a price before a run, and this account's token cannot read a
|
| 144 |
+
* balance — so no surface may present this as a billed figure.
|
| 145 |
+
*/
|
| 146 |
+
export interface DiscoverEstimate {
|
| 147 |
+
records: number;
|
| 148 |
+
usd: number;
|
| 149 |
+
unitUsd: number;
|
| 150 |
+
basis: string;
|
| 151 |
+
note: string;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
export interface UserTable {
|
| 155 |
key: string;
|
| 156 |
label: string;
|
|
|
|
| 249 |
* Flip one canvas node's switch.
|
| 250 |
*
|
| 251 |
* The client says WHICH NODE was clicked and nothing else — the server owns what
|
| 252 |
+
* each switch means (`engine.NODE_TOGGLES`). A client that knew "the Bright Data
|
| 253 |
* node writes config.tier" would be a second copy of that knowledge, free to
|
| 254 |
+
* drift from the engine that reads it — and it would have had to be rewritten
|
| 255 |
+
* when the vendor changed. It did not.
|
| 256 |
*/
|
| 257 |
export function toggleNode(
|
| 258 |
id: string,
|
|
|
|
| 272 |
return send(`/automations/${encodeURIComponent(id)}/rows`);
|
| 273 |
}
|
| 274 |
|
| 275 |
+
/** What would a search of this size cost? Asked BEFORE the run, never after. */
|
| 276 |
+
export function discoverEstimate(recordsLimit: number): Promise<DiscoverEstimate> {
|
| 277 |
+
return send("/automations/discover/estimate", {
|
| 278 |
+
method: "POST",
|
| 279 |
+
body: JSON.stringify({ recordsLimit }),
|
| 280 |
+
});
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
export function previewSource(
|
| 284 |
url: string,
|
| 285 |
extract: string,
|
|
|
|
| 338 |
["partial", "Profile only"],
|
| 339 |
["blocked", "Blocked"],
|
| 340 |
["error", "Errors"],
|
| 341 |
+
["paid", "Exact via Bright Data"],
|
| 342 |
["posts", "Posts"],
|
| 343 |
["new_posts", "New posts"],
|
| 344 |
+
// The engagement series only grows while the per-post rung is on, so its
|
| 345 |
+
// count is its own chip — "12 posts" and "0 engagement snapshots" together
|
| 346 |
+
// are the honest picture of a profile-only run.
|
| 347 |
+
["metrics", "Engagement snapshots"],
|
| 348 |
+
// Discovery.
|
| 349 |
+
["asked", "Asked for"],
|
| 350 |
+
["found", "Profiles found"],
|
| 351 |
+
["new", "New to us"],
|
| 352 |
+
["seen_again", "Seen before"],
|
| 353 |
+
["dropped", "Returned but unusable (no handle)"],
|
| 354 |
+
// ⚠ Its own chip for the same reason `capped` has one: a database that could
|
| 355 |
+
// not be CREATED is a system fact, and the run that hit it wrote nothing.
|
| 356 |
+
["missing_tables", "Databases that could not be created"],
|
| 357 |
];
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
| 14 |
EditableGridCell,
|
| 15 |
GridKeyEventArgs,
|
| 16 |
GridMouseEventArgs,
|
|
|
|
| 17 |
DrawHeaderCallback,
|
| 18 |
HeaderClickedEventArgs,
|
| 19 |
Item,
|
|
@@ -38,7 +39,11 @@ import ColumnMenu from "./ColumnMenu";
|
|
| 38 |
import type { ColumnMenuState } from "./ColumnMenu";
|
| 39 |
import { HEADER_ICONS } from "./iconShapes";
|
| 40 |
import { emitHostEvent, eventId } from "./hostBridge";
|
| 41 |
-
import { NAV_MINIMIZE_EVENT, TOAST_EVENT, signal } from "../apiContract";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
import { exportFilename, runExport, triggerDownload } from "./export";
|
| 43 |
import type { ExportFormat } from "./export";
|
| 44 |
// owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it.
|
|
@@ -48,7 +53,8 @@ import { echoReemit, reconcileEchoView } from "./viewEcho";
|
|
| 48 |
import { pruneStamps, reconcileFields } from "./optimism";
|
| 49 |
import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
|
| 50 |
import type { FolderStamps } from "./folders";
|
| 51 |
-
import { adoptNewFields, adoptNewViews, pruneTombstones, stampTombstone }
|
|
|
|
| 52 |
import type { Tombstones } from "./liveWorkspace";
|
| 53 |
import type { GridFolder, ViewPermissions } from "./types";
|
| 54 |
import type { FieldStamps } from "./optimism";
|
|
@@ -87,7 +93,7 @@ import { MAX_CALENDAR_METRICS,
|
|
| 87 |
MAX_FROZEN, choiceOptions, clampFrozenCount, cleanDisplay, formulaOf, topicForScope,
|
| 88 |
isDateFamilyType, isFilterGroup, isGroupableField, isNumericFieldType, isPickType, mayEditField,
|
| 89 |
isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
|
| 90 |
-
ratingMax,
|
| 91 |
tableMode, uniqueDisplayName } from "./types";
|
| 92 |
import type {
|
| 93 |
DisplayMode,
|
|
@@ -259,6 +265,11 @@ interface LocalWorkspace {
|
|
| 259 |
* after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the
|
| 260 |
* echo window would otherwise re-adopt a view the user deleted a second ago. */
|
| 261 |
viewTombstones?: Tombstones;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
function sameConfig(a: ViewConfig, b: ViewConfig): boolean {
|
|
@@ -329,26 +340,41 @@ function splitMulti(v: string): string[] {
|
|
| 329 |
.filter((s) => s !== "");
|
| 330 |
}
|
| 331 |
|
| 332 |
-
/** Wave-5 item 3 — does any leaf of the filter tree name this column?
|
| 333 |
-
*
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
for (const n of nodes) {
|
| 336 |
if (isFilterGroup(n)) {
|
| 337 |
-
if (treeNamesField(n.children, key)) return true;
|
| 338 |
-
} else if ((n as FilterRule).
|
| 339 |
}
|
| 340 |
return false;
|
| 341 |
}
|
| 342 |
|
| 343 |
/** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune
|
| 344 |
* groups that end up empty. Returns new arrays throughout (the config is state). */
|
| 345 |
-
function dropFieldFromTree(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
const out: FilterNode[] = [];
|
| 347 |
for (const n of nodes) {
|
| 348 |
if (isFilterGroup(n)) {
|
| 349 |
-
const children = dropFieldFromTree(n.children, key);
|
| 350 |
if (children.length) out.push({ ...n, children });
|
| 351 |
-
} else if ((n as FilterRule).
|
| 352 |
out.push(n);
|
| 353 |
}
|
| 354 |
}
|
|
@@ -376,6 +402,10 @@ interface FieldBuildExtra {
|
|
| 376 |
label?: string;
|
| 377 |
colorCodeOptions?: boolean;
|
| 378 |
optionColors?: Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
/**
|
|
@@ -498,6 +528,12 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 498 |
const [selAddOpen, setSelAddOpen] = useState(false);
|
| 499 |
const [selListName, setSelListName] = useState("");
|
| 500 |
const selAddRef = useRef<HTMLButtonElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
/** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks
|
| 502 |
* ACCUMULATE across searches (search, tick, search again, confirm once). */
|
| 503 |
const [activeCohortId, setActiveCohortId] = useState<string | null>(null);
|
|
@@ -540,6 +576,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 540 |
* init, so the mount path and the after-mount path answer "was this deleted here?" the
|
| 541 |
* same way. Persisted with the local workspace for the same reason `reemitted` is. */
|
| 542 |
const viewTombstonesRef = useRef<Tombstones>({});
|
|
|
|
|
|
|
| 543 |
const initializedKey = useRef<string | null>(null);
|
| 544 |
const saveTimer = useRef<number | null>(null);
|
| 545 |
const gridRef = useRef<DataEditorRef>(null);
|
|
@@ -591,7 +629,13 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 591 |
const hostViews = payload.workspace?.views ?? [];
|
| 592 |
const byId = new Map<string, SavedView>();
|
| 593 |
byId.set(ALL_VIEW_ID, allCustomersView(initialFields));
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
// Host state wins — EXCEPT when it is a lagged echo of this browser's own
|
| 596 |
// in-flight edit, where taking it would turn a just-completed measure rule
|
| 597 |
// valueless: inactive, no pending marker, whole-book count. A rerun replaces
|
|
@@ -664,7 +708,10 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 664 |
for (const view of initialViews) {
|
| 665 |
const key = reemit.get(view.id);
|
| 666 |
if (key === undefined) continue;
|
| 667 |
-
|
|
|
|
|
|
|
|
|
|
| 668 |
stamped[view.id] = key;
|
| 669 |
}
|
| 670 |
reemittedRef.current = stamped;
|
|
@@ -687,6 +734,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 687 |
// Pruned on the way out, like every other stamp map here: the persisted blob is a
|
| 688 |
// recent window, never an archive of everything this tab ever deleted.
|
| 689 |
viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()),
|
|
|
|
|
|
|
|
|
|
| 690 |
});
|
| 691 |
}, [workspaceReady, storageKey, fields, views, activeViewId]);
|
| 692 |
|
|
@@ -765,6 +815,50 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 765 |
insertColumn,
|
| 766 |
} = useGridColumns(fields, config, updateConfig);
|
| 767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
// Airtable behavior: configuration changes to the active view autosave.
|
| 769 |
useEffect(() => {
|
| 770 |
if (!workspaceReady) return;
|
|
@@ -780,6 +874,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 780 |
setViews((current) =>
|
| 781 |
current.map((view) => (view.id === updated.id ? updated : view))
|
| 782 |
);
|
|
|
|
|
|
|
| 783 |
emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
|
| 784 |
setSaveState("saved");
|
| 785 |
saveTimer.current = null;
|
|
@@ -1122,6 +1218,223 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1122 |
displayPidToIndex,
|
| 1123 |
visibleCols.length
|
| 1124 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
/** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built
|
| 1126 |
* from `order` (already reconciled by useGridColumns) rather than from `config.order` so the
|
| 1127 |
* panel and the grid can never disagree about which fields exist or where they sit. */
|
|
@@ -1334,6 +1647,15 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1334 |
let rowsPx = HEADER_PX;
|
| 1335 |
if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight;
|
| 1336 |
else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1337 |
|
| 1338 |
/* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same
|
| 1339 |
`clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js:
|
|
@@ -1355,7 +1677,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1355 |
below: rowsPx < clientH ? rowsPx : null,
|
| 1356 |
right: colsPx < clientW ? colsPx : null,
|
| 1357 |
};
|
| 1358 |
-
}, [visibleCols, displayRows, rowHeight, gridSize]);
|
| 1359 |
/* ═══ end W18-B VOID (geometry) ═══ */
|
| 1360 |
|
| 1361 |
// The record drawer resolves positions against what the MODE paints: the display slice for
|
|
@@ -1619,18 +1941,18 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1619 |
// Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay
|
| 1620 |
// editor); the overlay store keeps its '1'-or-empty contract.
|
| 1621 |
if (value.kind === GridCellKind.Boolean && field.type === "checkbox") {
|
| 1622 |
-
|
| 1623 |
return;
|
| 1624 |
}
|
| 1625 |
if (value.kind === GridCellKind.Uri) {
|
| 1626 |
-
|
| 1627 |
return;
|
| 1628 |
}
|
| 1629 |
if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) {
|
| 1630 |
-
|
| 1631 |
}
|
| 1632 |
},
|
| 1633 |
-
[displayRows, visibleCols, fieldByKey,
|
| 1634 |
);
|
| 1635 |
const validateCell = useCallback(
|
| 1636 |
(cell: Item): boolean => {
|
|
@@ -1676,13 +1998,17 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1676 |
allowedChoices: statusValues[field.key] ?? choiceOptions(field),
|
| 1677 |
});
|
| 1678 |
if (!patches) return false;
|
| 1679 |
-
for
|
| 1680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1681 |
// We handled the write ourselves. Returning false tells glide not to run its cell
|
| 1682 |
// renderers' generic paste path (Bubble/rating renderers cannot enforce this contract).
|
| 1683 |
return false;
|
| 1684 |
},
|
| 1685 |
-
[visibleCols, fieldByKey, canEditField, statusValues, displayRows,
|
| 1686 |
gridSelection.current]
|
| 1687 |
);
|
| 1688 |
|
|
@@ -2623,8 +2949,23 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 2623 |
...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
|
| 2624 |
};
|
| 2625 |
saveField(next);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2626 |
},
|
| 2627 |
-
[fieldByKey, saveField]
|
| 2628 |
);
|
| 2629 |
|
| 2630 |
/**
|
|
@@ -2713,6 +3054,61 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 2713 |
}, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]);
|
| 2714 |
|
| 2715 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2716 |
/** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */
|
| 2717 |
const addCustomersToCohort = useCallback(() => {
|
| 2718 |
if (!activeCohortId || addCustPicked.size === 0) return;
|
|
@@ -2981,9 +3377,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 2981 |
const onKanbanMove = useCallback(
|
| 2982 |
(pid: number, value: string) => {
|
| 2983 |
if (!kanbanField) return;
|
| 2984 |
-
|
| 2985 |
},
|
| 2986 |
-
[kanbanField,
|
| 2987 |
);
|
| 2988 |
const onListToggleGroup = useCallback((groupKey: string) => {
|
| 2989 |
setCollapsed((current) => {
|
|
@@ -3101,7 +3497,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3101 |
? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null
|
| 3102 |
: null;
|
| 3103 |
const menuIsFiltered = menuField
|
| 3104 |
-
? treeNamesField(config.filters, menuField.key)
|
| 3105 |
: false;
|
| 3106 |
// Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)?
|
| 3107 |
const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
|
|
@@ -3414,6 +3810,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3414 |
onCellActivated={onCellActivated}
|
| 3415 |
onCellEdited={onCellEdited}
|
| 3416 |
onPaste={onGridPaste}
|
|
|
|
|
|
|
|
|
|
| 3417 |
validateCell={validateCell}
|
| 3418 |
onColumnResize={onColumnResize}
|
| 3419 |
onColumnMoved={onColumnMoved}
|
|
@@ -3433,9 +3832,23 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3433 |
customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
|
| 3434 |
headerIcons={HEADER_ICONS}
|
| 3435 |
rightElement={
|
| 3436 |
-
// Wave-6 item 4 — the
|
| 3437 |
-
//
|
| 3438 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3439 |
<button
|
| 3440 |
type="button"
|
| 3441 |
className="cg-add-field-btn"
|
|
@@ -3456,7 +3869,22 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3456 |
+
|
| 3457 |
</button>
|
| 3458 |
}
|
| 3459 |
-
rightElementProps={{ sticky:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3460 |
/>
|
| 3461 |
{/* ═══ W18-B VOID ═══ (wave 18, owner item 1b / ruling R12) — the two rectangles.
|
| 3462 |
|
|
@@ -3740,6 +4168,24 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3740 |
Add to cohort
|
| 3741 |
</button>
|
| 3742 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3743 |
<button type="button" className="cg-btn" style={ONE_LINE}
|
| 3744 |
onClick={clearSelection}>
|
| 3745 |
Clear
|
|
@@ -3880,7 +4326,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3880 |
onClearFilter={() =>
|
| 3881 |
updateConfig({
|
| 3882 |
...config,
|
| 3883 |
-
filters: dropFieldFromTree(config.filters, menuField.key),
|
| 3884 |
})
|
| 3885 |
}
|
| 3886 |
onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
|
|
@@ -3982,6 +4428,41 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3982 |
</AnchoredOverlay>
|
| 3983 |
)}
|
| 3984 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3985 |
{/* Cohort mode's "+ Add customers" picker (item 2c): search the POOL, tick many, confirm
|
| 3986 |
once. Candidates exclude current members; picks ACCUMULATE across searches, so the
|
| 3987 |
confirm button carries the running total. The 50-row render cap is stated beside the
|
|
@@ -4122,7 +4603,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 4122 |
}
|
| 4123 |
aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
| 4124 |
onClick={() => {
|
| 4125 |
-
|
| 4126 |
closePicker();
|
| 4127 |
}}
|
| 4128 |
>
|
|
@@ -4160,12 +4641,12 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 4160 |
const next = on
|
| 4161 |
? pickerParts.filter((p) => p !== choice)
|
| 4162 |
: [...pickerParts, choice];
|
| 4163 |
-
|
| 4164 |
[pickerField.key]: next.join(","),
|
| 4165 |
-
});
|
| 4166 |
return;
|
| 4167 |
}
|
| 4168 |
-
|
| 4169 |
closePicker();
|
| 4170 |
}}
|
| 4171 |
>
|
|
@@ -4255,7 +4736,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 4255 |
// (the querySelector takes the FIRST match, so a real choice row wins).
|
| 4256 |
data-overlay-autofocus={pickerChoices.length === 0 ? true : undefined}
|
| 4257 |
onClick={() => {
|
| 4258 |
-
|
| 4259 |
closePicker();
|
| 4260 |
}}
|
| 4261 |
>
|
|
@@ -4329,7 +4810,7 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 4329 |
[detailPid]: { ...current[detailPid], [key]: value },
|
| 4330 |
}))
|
| 4331 |
}
|
| 4332 |
-
onNotesCommit={(key, value) =>
|
| 4333 |
viewer={viewer}
|
| 4334 |
// Wave-14 close-out stitch (HOST): TSREC's two light-switch props, unwired when
|
| 4335 |
// the GRID session ended — item 9's picker vocabulary + item 11's photos. Same
|
|
|
|
| 14 |
EditableGridCell,
|
| 15 |
GridKeyEventArgs,
|
| 16 |
GridMouseEventArgs,
|
| 17 |
+
GridSelection,
|
| 18 |
DrawHeaderCallback,
|
| 19 |
HeaderClickedEventArgs,
|
| 20 |
Item,
|
|
|
|
| 39 |
import type { ColumnMenuState } from "./ColumnMenu";
|
| 40 |
import { HEADER_ICONS } from "./iconShapes";
|
| 41 |
import { emitHostEvent, eventId } from "./hostBridge";
|
| 42 |
+
import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, signal } from "../apiContract";
|
| 43 |
+
import { addTableRow, deleteTableRow } from "./apiBridge";
|
| 44 |
+
// Owner item 16 / R4 / C-UNDO — the stack and every inverse. Pure, so a node gate can run it.
|
| 45 |
+
import { describe, directed, popRedo, popUndo, pushUndo, stackFor } from "./undoStack";
|
| 46 |
+
import type { CellChange, UndoBook, UndoEntry, UndoValue } from "./undoStack";
|
| 47 |
import { exportFilename, runExport, triggerDownload } from "./export";
|
| 48 |
import type { ExportFormat } from "./export";
|
| 49 |
// owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it.
|
|
|
|
| 53 |
import { pruneStamps, reconcileFields } from "./optimism";
|
| 54 |
import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
|
| 55 |
import type { FolderStamps } from "./folders";
|
| 56 |
+
import { adoptNewFields, adoptNewViews, pruneTombstones, seedLocalViews, stampTombstone }
|
| 57 |
+
from "./liveWorkspace";
|
| 58 |
import type { Tombstones } from "./liveWorkspace";
|
| 59 |
import type { GridFolder, ViewPermissions } from "./types";
|
| 60 |
import type { FieldStamps } from "./optimism";
|
|
|
|
| 93 |
MAX_FROZEN, choiceOptions, clampFrozenCount, cleanDisplay, formulaOf, topicForScope,
|
| 94 |
isDateFamilyType, isFilterGroup, isGroupableField, isNumericFieldType, isPickType, mayEditField,
|
| 95 |
isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
|
| 96 |
+
measureColumnIndex, ratingMax, ruleColumnKeys,
|
| 97 |
tableMode, uniqueDisplayName } from "./types";
|
| 98 |
import type {
|
| 99 |
DisplayMode,
|
|
|
|
| 265 |
* after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the
|
| 266 |
* echo window would otherwise re-adopt a view the user deleted a second ago. */
|
| 267 |
viewTombstones?: Tombstones;
|
| 268 |
+
/** D-19 (wave 20) — viewId → when THIS browser last WROTE it. The counterpart of the
|
| 269 |
+
* tombstones above: they stop a deleted view coming back, this stops a just-created one
|
| 270 |
+
* being dropped by the host-list rule at init, before its upsert has round-tripped.
|
| 271 |
+
* Same window, same prune, same browser-clock caveat. */
|
| 272 |
+
viewWrites?: Tombstones;
|
| 273 |
}
|
| 274 |
|
| 275 |
function sameConfig(a: ViewConfig, b: ViewConfig): boolean {
|
|
|
|
| 340 |
.filter((s) => s !== "");
|
| 341 |
}
|
| 342 |
|
| 343 |
+
/** Wave-5 item 3 — does any leaf of the filter tree name this column?
|
| 344 |
+
*
|
| 345 |
+
* ⚠ Wave-20 item 2 RETARGETED the note that stood here ("measure leaves carry measure keys,
|
| 346 |
+
* which are never field keys, so they cannot false-positive"). True about false POSITIVES,
|
| 347 |
+
* and precisely why it was a false NEGATIVE: a measure column's own condition names the
|
| 348 |
+
* MEASURE, so this answered "not filtered" for a column the user had visibly filtered, and
|
| 349 |
+
* the menu never offered "Don't filter by this field" on it. Both doors now go through
|
| 350 |
+
* `ruleColumnKeys` — the same resolution the tint uses, so the menu and the colour can never
|
| 351 |
+
* disagree about which columns a filter is about. */
|
| 352 |
+
function treeNamesField(
|
| 353 |
+
nodes: FilterNode[],
|
| 354 |
+
key: string,
|
| 355 |
+
measureCols: Map<string, string[]>
|
| 356 |
+
): boolean {
|
| 357 |
for (const n of nodes) {
|
| 358 |
if (isFilterGroup(n)) {
|
| 359 |
+
if (treeNamesField(n.children, key, measureCols)) return true;
|
| 360 |
+
} else if (ruleColumnKeys(n as FilterRule, measureCols).includes(key)) return true;
|
| 361 |
}
|
| 362 |
return false;
|
| 363 |
}
|
| 364 |
|
| 365 |
/** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune
|
| 366 |
* groups that end up empty. Returns new arrays throughout (the config is state). */
|
| 367 |
+
function dropFieldFromTree(
|
| 368 |
+
nodes: FilterNode[],
|
| 369 |
+
key: string,
|
| 370 |
+
measureCols: Map<string, string[]>
|
| 371 |
+
): FilterNode[] {
|
| 372 |
const out: FilterNode[] = [];
|
| 373 |
for (const n of nodes) {
|
| 374 |
if (isFilterGroup(n)) {
|
| 375 |
+
const children = dropFieldFromTree(n.children, key, measureCols);
|
| 376 |
if (children.length) out.push({ ...n, children });
|
| 377 |
+
} else if (!ruleColumnKeys(n as FilterRule, measureCols).includes(key)) {
|
| 378 |
out.push(n);
|
| 379 |
}
|
| 380 |
}
|
|
|
|
| 402 |
label?: string;
|
| 403 |
colorCodeOptions?: boolean;
|
| 404 |
optionColors?: Record<string, string>;
|
| 405 |
+
/** Item 15 (C-RENAME) — the option renames this save carries, by row identity. Consumed by
|
| 406 |
+
* `retypeField` (which emits `choice_rename`) and ignored by every create path: a field
|
| 407 |
+
* being CREATED has no values to migrate. */
|
| 408 |
+
renames?: { from: string; to: string }[];
|
| 409 |
}
|
| 410 |
|
| 411 |
/**
|
|
|
|
| 528 |
const [selAddOpen, setSelAddOpen] = useState(false);
|
| 529 |
const [selListName, setSelListName] = useState("");
|
| 530 |
const selAddRef = useRef<HTMLButtonElement>(null);
|
| 531 |
+
/** Owner item 9 (wave 20) — its counterpart: "Remove from cohort…", in ORDINARY views. */
|
| 532 |
+
const [selRemoveOpen, setSelRemoveOpen] = useState(false);
|
| 533 |
+
const selRemoveRef = useRef<HTMLButtonElement>(null);
|
| 534 |
+
/** Owner item 4 / C-ADDROW — the pid the trailing "+" just created, so the cursor can land on
|
| 535 |
+
* it once the re-read actually brings it back (the row does not exist locally before that). */
|
| 536 |
+
const [newRowPid, setNewRowPid] = useState<number | null>(null);
|
| 537 |
/** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks
|
| 538 |
* ACCUMULATE across searches (search, tick, search again, confirm once). */
|
| 539 |
const [activeCohortId, setActiveCohortId] = useState<string | null>(null);
|
|
|
|
| 576 |
* init, so the mount path and the after-mount path answer "was this deleted here?" the
|
| 577 |
* same way. Persisted with the local workspace for the same reason `reemitted` is. */
|
| 578 |
const viewTombstonesRef = useRef<Tombstones>({});
|
| 579 |
+
/** D-19 — viewId → when this browser last wrote it (see LocalWorkspace.viewWrites). */
|
| 580 |
+
const viewWritesRef = useRef<Tombstones>({});
|
| 581 |
const initializedKey = useRef<string | null>(null);
|
| 582 |
const saveTimer = useRef<number | null>(null);
|
| 583 |
const gridRef = useRef<DataEditorRef>(null);
|
|
|
|
| 629 |
const hostViews = payload.workspace?.views ?? [];
|
| 630 |
const byId = new Map<string, SavedView>();
|
| 631 |
byId.set(ALL_VIEW_ID, allCustomersView(initialFields));
|
| 632 |
+
// D-19 — the HOST'S LIST DECIDES WHICH VIEWS EXIST. A local copy the host no longer
|
| 633 |
+
// names is a ghost (deleted elsewhere, share revoked, store moved) and is dropped here,
|
| 634 |
+
// guarded by this browser's own recent writes so an optimistic create survives.
|
| 635 |
+
viewWritesRef.current = pruneTombstones(local?.viewWrites, Date.now());
|
| 636 |
+
for (const view of seedLocalViews(local?.views ?? [], hostViews, viewWritesRef.current,
|
| 637 |
+
Date.now(), payload.workspace != null))
|
| 638 |
+
byId.set(view.id, view);
|
| 639 |
// Host state wins — EXCEPT when it is a lagged echo of this browser's own
|
| 640 |
// in-flight edit, where taking it would turn a just-completed measure rule
|
| 641 |
// valueless: inactive, no pending marker, whole-book count. A rerun replaces
|
|
|
|
| 708 |
for (const view of initialViews) {
|
| 709 |
const key = reemit.get(view.id);
|
| 710 |
if (key === undefined) continue;
|
| 711 |
+
viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id,
|
| 712 |
+
Date.now());
|
| 713 |
+
viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, Date.now());
|
| 714 |
+
emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
|
| 715 |
stamped[view.id] = key;
|
| 716 |
}
|
| 717 |
reemittedRef.current = stamped;
|
|
|
|
| 734 |
// Pruned on the way out, like every other stamp map here: the persisted blob is a
|
| 735 |
// recent window, never an archive of everything this tab ever deleted.
|
| 736 |
viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()),
|
| 737 |
+
// D-19's other half — without persisting these, a reload inside the echo window would
|
| 738 |
+
// drop a view this browser created seconds ago.
|
| 739 |
+
viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
|
| 740 |
});
|
| 741 |
}, [workspaceReady, storageKey, fields, views, activeViewId]);
|
| 742 |
|
|
|
|
| 815 |
insertColumn,
|
| 816 |
} = useGridColumns(fields, config, updateConfig);
|
| 817 |
|
| 818 |
+
/**
|
| 819 |
+
* ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) — **THE GHOST ROW.**
|
| 820 |
+
*
|
| 821 |
+
* A trailing "+" row at the bottom of the grid, the way Airtable grows a table, replacing the
|
| 822 |
+
* "Add record" button that used to sit in the shell's header bar (S4 deleted it this wave).
|
| 823 |
+
*
|
| 824 |
+
* ⛔ USER DATABASES ONLY. A connector's rows are read-synced from Odoo; a "+" there could only
|
| 825 |
+
* refuse, and R8 names that a fake affordance. The test is the SCOPE (`ut_*`), which is also
|
| 826 |
+
* the only scope with a rows endpoint to POST to — so the affordance and the capability come
|
| 827 |
+
* from the same fact rather than from two lists that can drift.
|
| 828 |
+
*
|
| 829 |
+
* ⚠ THE ROW IS NOT ADDED LOCALLY. `rawRows` is the server's answer, and a client-invented row
|
| 830 |
+
* would have no rid, no defaults and no place in anyone else's copy. The POST clears the rows
|
| 831 |
+
* cache and fires `ROWS_STALE_EVENT`, `useCustomerData` re-reads, and the row arrives with the
|
| 832 |
+
* id the store gave it. The cursor then follows it (`newRowPid` + the effect below) — which is
|
| 833 |
+
* why the pid is remembered instead of glide's `"bottom"` being returned here: at the moment
|
| 834 |
+
* this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
|
| 835 |
+
* row.
|
| 836 |
+
*/
|
| 837 |
+
const isUserTable = scope.startsWith("ut_");
|
| 838 |
+
const appendRow = useCallback(async (): Promise<undefined> => {
|
| 839 |
+
if (!isUserTable) return undefined;
|
| 840 |
+
const made = await addTableRow(scope);
|
| 841 |
+
// `undefined` either way, never glide's "bottom": glide would move the selection to the
|
| 842 |
+
// last row it currently knows about, which is the row BEFORE the one just created. The
|
| 843 |
+
// effect below lands the cursor when the re-read brings the real one in.
|
| 844 |
+
if (!made) return undefined; // addTableRow already said why, in the server's words
|
| 845 |
+
setNewRowPid(made.pid);
|
| 846 |
+
// R4 — the append is undoable: Ctrl+Z deletes the row it just created, and a redo restores
|
| 847 |
+
// it under the SAME rid (the server's `{rid}` passthrough, C-ADDROW). An empty new record
|
| 848 |
+
// carries no values, so the entry is the id and nothing else.
|
| 849 |
+
undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| 850 |
+
kind: "rowAdd", table: scope, rid: made.rid, values: {},
|
| 851 |
+
});
|
| 852 |
+
signal(ROWS_STALE_EVENT);
|
| 853 |
+
return undefined;
|
| 854 |
+
}, [isUserTable, scope]);
|
| 855 |
+
|
| 856 |
+
/* wave20 item 2 — measure key + window -> the columns that display it. Built ONCE per field
|
| 857 |
+
list and handed to every consumer of "which column is this rule about", so the tint
|
| 858 |
+
(`columnTones`, inside the hook above) and the column menu's filter doors resolve a
|
| 859 |
+
measure condition the same way. See `types.ruleColumnKeys`. */
|
| 860 |
+
const measureCols = useMemo(() => measureColumnIndex(fields), [fields]);
|
| 861 |
+
|
| 862 |
// Airtable behavior: configuration changes to the active view autosave.
|
| 863 |
useEffect(() => {
|
| 864 |
if (!workspaceReady) return;
|
|
|
|
| 874 |
setViews((current) =>
|
| 875 |
current.map((view) => (view.id === updated.id ? updated : view))
|
| 876 |
);
|
| 877 |
+
viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id,
|
| 878 |
+
Date.now());
|
| 879 |
emitHostEvent({ id: eventId("view"), type: "view_upsert", view: updated });
|
| 880 |
setSaveState("saved");
|
| 881 |
saveTimer.current = null;
|
|
|
|
| 1218 |
displayPidToIndex,
|
| 1219 |
visibleCols.length
|
| 1220 |
);
|
| 1221 |
+
/* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
|
| 1222 |
+
THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
|
| 1223 |
+
thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
|
| 1224 |
+
call site. Everything below funnels through `patchAndRecord` / `patchManyAndRecord`, so a
|
| 1225 |
+
write path that forgets to record is a write path that does not reach the store either.
|
| 1226 |
+
|
| 1227 |
+
⚠ A REF, NOT STATE. Nothing on screen depends on the stack in v1 (no undo button), so
|
| 1228 |
+
keeping it in state would repaint the grid on every keystroke of a paste for no pixels.
|
| 1229 |
+
Per SCOPE (a Ctrl+Z on the Customer grid must never rewrite a user table's cell) and per
|
| 1230 |
+
tab (nothing persists it — a stack restored into a session that did not make those edits
|
| 1231 |
+
would undo somebody else's work). */
|
| 1232 |
+
const undoBook = useRef<UndoBook>({});
|
| 1233 |
+
const applyingUndo = useRef(false);
|
| 1234 |
+
|
| 1235 |
+
/** The value a cell holds RIGHT NOW: the overlay stratum wins over the payload row, exactly
|
| 1236 |
+
* as `useGetCellContent` renders it — so what undo restores is what was on screen. */
|
| 1237 |
+
const rowByPid = useMemo(() => {
|
| 1238 |
+
const map = new Map<number, Row>();
|
| 1239 |
+
for (const r of rawRows) map.set(r.pid, r);
|
| 1240 |
+
return map;
|
| 1241 |
+
}, [rawRows]);
|
| 1242 |
+
const currentValue = useCallback(
|
| 1243 |
+
(pid: number, key: string): UndoValue => {
|
| 1244 |
+
const edited = overlayEdits[pid]?.[key];
|
| 1245 |
+
const raw = edited !== undefined ? edited : rowByPid.get(pid)?.[key];
|
| 1246 |
+
return raw == null ? null : (raw as UndoValue);
|
| 1247 |
+
},
|
| 1248 |
+
[overlayEdits, rowByPid]
|
| 1249 |
+
);
|
| 1250 |
+
|
| 1251 |
+
const recordCells = useCallback(
|
| 1252 |
+
(changes: CellChange[], label: string) => {
|
| 1253 |
+
if (applyingUndo.current || changes.length === 0) return;
|
| 1254 |
+
undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| 1255 |
+
kind: "cells", label, changes,
|
| 1256 |
+
});
|
| 1257 |
+
},
|
| 1258 |
+
[scope]
|
| 1259 |
+
);
|
| 1260 |
+
|
| 1261 |
+
/** ONE cell write, recorded. Every editor, picker and drag goes through this. */
|
| 1262 |
+
const patchAndRecord = useCallback(
|
| 1263 |
+
(pid: number, updates: Partial<Row>, label = "an edit") => {
|
| 1264 |
+
const changes: CellChange[] = Object.entries(updates).map(([key, value]) => ({
|
| 1265 |
+
pid, key,
|
| 1266 |
+
before: currentValue(pid, key),
|
| 1267 |
+
after: (value ?? null) as UndoValue,
|
| 1268 |
+
}));
|
| 1269 |
+
patchOverlay(pid, updates);
|
| 1270 |
+
recordCells(changes, label);
|
| 1271 |
+
},
|
| 1272 |
+
[currentValue, patchOverlay, recordCells]
|
| 1273 |
+
);
|
| 1274 |
+
|
| 1275 |
+
/** MANY cells, ONE stack entry — a paste and a bulk clear are each one user action (R4). */
|
| 1276 |
+
const patchManyAndRecord = useCallback(
|
| 1277 |
+
(writes: { pid: number; updates: Partial<Row> }[], label: string) => {
|
| 1278 |
+
const changes: CellChange[] = [];
|
| 1279 |
+
for (const w of writes)
|
| 1280 |
+
for (const [key, value] of Object.entries(w.updates))
|
| 1281 |
+
changes.push({
|
| 1282 |
+
pid: w.pid, key,
|
| 1283 |
+
before: currentValue(w.pid, key),
|
| 1284 |
+
after: (value ?? null) as UndoValue,
|
| 1285 |
+
});
|
| 1286 |
+
for (const w of writes) patchOverlay(w.pid, w.updates);
|
| 1287 |
+
recordCells(changes, label);
|
| 1288 |
+
},
|
| 1289 |
+
[currentValue, patchOverlay, recordCells]
|
| 1290 |
+
);
|
| 1291 |
+
|
| 1292 |
+
/**
|
| 1293 |
+
* Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` — this
|
| 1294 |
+
* only knows how to WRITE each op, and it writes through the same doors the user does
|
| 1295 |
+
* (`patchOverlay`, the rows endpoint), so an undone edit is persisted exactly like the edit
|
| 1296 |
+
* was. Nothing here is optimistic-only: a Ctrl+Z that reverted the screen and not the store
|
| 1297 |
+
* would come back on the next reload.
|
| 1298 |
+
*/
|
| 1299 |
+
const applyEntry = useCallback(
|
| 1300 |
+
async (entry: UndoEntry, dir: "back" | "forward") => {
|
| 1301 |
+
const op = directed(entry, dir);
|
| 1302 |
+
if (op.kind === "cells") {
|
| 1303 |
+
const byPid = new Map<number, Partial<Row>>();
|
| 1304 |
+
for (const c of op.changes) {
|
| 1305 |
+
const at = byPid.get(c.pid) ?? {};
|
| 1306 |
+
at[c.key] = (c.after ?? "") as Row[string];
|
| 1307 |
+
byPid.set(c.pid, at);
|
| 1308 |
+
}
|
| 1309 |
+
applyingUndo.current = true;
|
| 1310 |
+
try {
|
| 1311 |
+
for (const [pid, updates] of byPid) patchOverlay(pid, updates);
|
| 1312 |
+
} finally {
|
| 1313 |
+
applyingUndo.current = false;
|
| 1314 |
+
}
|
| 1315 |
+
} else if (op.kind === "rowAdd") {
|
| 1316 |
+
// Restore under the OLD id where the store still has it free; the server answers with
|
| 1317 |
+
// what it actually wrote and the re-read is what puts the row back on screen.
|
| 1318 |
+
const made = await addTableRow(op.table, op.values as Record<string, unknown>, op.rid);
|
| 1319 |
+
if (!made) return; // the server already said why
|
| 1320 |
+
signal(ROWS_STALE_EVENT);
|
| 1321 |
+
} else if (op.kind === "rowDelete") {
|
| 1322 |
+
const ok = await deleteTableRow(op.table, op.rid);
|
| 1323 |
+
if (!ok) return;
|
| 1324 |
+
signal(ROWS_STALE_EVENT);
|
| 1325 |
+
} else if (op.kind === "choiceRename") {
|
| 1326 |
+
// Item 15 — the inverse mapping. The host rewrites the values and the saved views that
|
| 1327 |
+
// name them, exactly as it did on the way out; `directed` already turned the pairs
|
| 1328 |
+
// around, so this emits what it is given.
|
| 1329 |
+
emitHostEvent({
|
| 1330 |
+
id: eventId("choicerename"),
|
| 1331 |
+
type: "choice_rename",
|
| 1332 |
+
key: op.fieldKey,
|
| 1333 |
+
renames: op.renames,
|
| 1334 |
+
});
|
| 1335 |
+
}
|
| 1336 |
+
signal(TOAST_EVENT, describe(entry, dir));
|
| 1337 |
+
},
|
| 1338 |
+
[patchOverlay]
|
| 1339 |
+
);
|
| 1340 |
+
|
| 1341 |
+
/**
|
| 1342 |
+
* Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y, which is the same request on Windows).
|
| 1343 |
+
*
|
| 1344 |
+
* ⚠ ON `window`, IN CAPTURE, and it steps aside for real text fields. The grid is a canvas —
|
| 1345 |
+
* glide's key handling only fires while the canvas has focus, so a Ctrl+Z after clicking the
|
| 1346 |
+
* toolbar would do nothing, which is exactly the "sometimes it works" the owner would report
|
| 1347 |
+
* next. But an <input> with a cursor in it has its OWN undo that belongs to the browser, and
|
| 1348 |
+
* stealing that would be worse than not having ours: the search box, the rename field and
|
| 1349 |
+
* glide's own cell editor are all inputs, so `activeElement` decides.
|
| 1350 |
+
*
|
| 1351 |
+
* Nothing to undo says so out loud rather than silently ignoring the key — an undo that
|
| 1352 |
+
* appears to do nothing is indistinguishable from one that is broken.
|
| 1353 |
+
*/
|
| 1354 |
+
useEffect(() => {
|
| 1355 |
+
const onKey = (event: KeyboardEvent) => {
|
| 1356 |
+
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
| 1357 |
+
const key = event.key.toLowerCase();
|
| 1358 |
+
if (key !== "z" && key !== "y") return;
|
| 1359 |
+
const el = document.activeElement as HTMLElement | null;
|
| 1360 |
+
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable))
|
| 1361 |
+
return;
|
| 1362 |
+
const forward = key === "y" || event.shiftKey;
|
| 1363 |
+
event.preventDefault();
|
| 1364 |
+
const book = undoBook.current;
|
| 1365 |
+
const state = stackFor(book, scope);
|
| 1366 |
+
const { state: next, entry } = forward ? popRedo(state) : popUndo(state);
|
| 1367 |
+
if (!entry) {
|
| 1368 |
+
signal(TOAST_EVENT, forward ? "Nothing to redo." : "Nothing to undo.");
|
| 1369 |
+
return;
|
| 1370 |
+
}
|
| 1371 |
+
book[scope] = next;
|
| 1372 |
+
void applyEntry(entry, forward ? "forward" : "back");
|
| 1373 |
+
};
|
| 1374 |
+
window.addEventListener("keydown", onKey, true);
|
| 1375 |
+
return () => window.removeEventListener("keydown", onKey, true);
|
| 1376 |
+
}, [scope, applyEntry]);
|
| 1377 |
+
|
| 1378 |
+
/**
|
| 1379 |
+
* R4 — **BULK BACKSPACE/DELETE IS ONE ACTION.** glide's own delete walks the selection and
|
| 1380 |
+
* calls `onCellEdited` per cell, which would put forty entries on the stack for one keypress;
|
| 1381 |
+
* returning `false` takes the whole operation over so it lands as one.
|
| 1382 |
+
*
|
| 1383 |
+
* ⚠ PRESETS ARE NEVER ATTEMPTED (the contract says so, and it is also the only honest
|
| 1384 |
+
* behaviour): `canEditField` is the same verdict the editor and the paste path use, so a
|
| 1385 |
+
* selection spanning read-only columns clears the editable ones and leaves the rest exactly
|
| 1386 |
+
* as they were — rather than firing writes the server will refuse one by one.
|
| 1387 |
+
*/
|
| 1388 |
+
const onGridDelete = useCallback(
|
| 1389 |
+
(sel: GridSelection): boolean => {
|
| 1390 |
+
const byPid = new Map<number, Partial<Row>>();
|
| 1391 |
+
const clear = (rowIndex: number, colIndex: number) => {
|
| 1392 |
+
const vr = displayRows[rowIndex];
|
| 1393 |
+
if (!vr || vr.kind !== "data") return;
|
| 1394 |
+
const column = visibleCols[colIndex];
|
| 1395 |
+
const field = column ? fieldByKey.get(column.id!) : undefined;
|
| 1396 |
+
if (!field || !canEditField(field)) return;
|
| 1397 |
+
const at = byPid.get(vr.record.pid) ?? {};
|
| 1398 |
+
at[field.key] = "";
|
| 1399 |
+
byPid.set(vr.record.pid, at);
|
| 1400 |
+
};
|
| 1401 |
+
for (const rowIndex of sel.rows)
|
| 1402 |
+
for (let c = 0; c < visibleCols.length; c++) clear(rowIndex, c);
|
| 1403 |
+
const range = sel.current?.range;
|
| 1404 |
+
if (range)
|
| 1405 |
+
for (let y = range.y; y < range.y + range.height; y++)
|
| 1406 |
+
for (let x = range.x; x < range.x + range.width; x++) clear(y, x);
|
| 1407 |
+
const writes = [...byPid.entries()].map(([pid, updates]) => ({ pid, updates }));
|
| 1408 |
+
if (!writes.length) return true;
|
| 1409 |
+
patchManyAndRecord(writes, "clearing cells");
|
| 1410 |
+
// ⚠ `false` ONLY WHEN THIS ACTUALLY DID THE WORK. Returning it unconditionally cancels
|
| 1411 |
+
// glide's own delete for cases this handler does not cover — a COLUMN selection, which
|
| 1412 |
+
// glide deletes from `toDelete.columns` and the loops above never look at — so Delete
|
| 1413 |
+
// would silently clear nothing. Handing the keypress back when there is nothing to
|
| 1414 |
+
// group is strictly safer than swallowing it: the per-cell path still refuses read-only
|
| 1415 |
+
// fields (`onCellEdited`'s own `canEditField`), it just does not arrive as one entry.
|
| 1416 |
+
return false;
|
| 1417 |
+
},
|
| 1418 |
+
[displayRows, visibleCols, fieldByKey, canEditField, patchManyAndRecord]
|
| 1419 |
+
);
|
| 1420 |
+
|
| 1421 |
+
/**
|
| 1422 |
+
* Owner item 4 / C-ADDROW — the cursor FOLLOWS the appended row, once it exists.
|
| 1423 |
+
*
|
| 1424 |
+
* The append is a server round trip, so at click time there is nothing to focus; this waits
|
| 1425 |
+
* for the re-read to bring the pid back and then lands the active cell on its first column,
|
| 1426 |
+
* scrolled into view. `newRowPid` is cleared either way — a row the re-read never produced
|
| 1427 |
+
* (a refused write, a filter that excludes it) must not leave a cursor waiting forever.
|
| 1428 |
+
*/
|
| 1429 |
+
useEffect(() => {
|
| 1430 |
+
if (newRowPid === null) return;
|
| 1431 |
+
const index = displayPidToIndex.get(newRowPid);
|
| 1432 |
+
if (index === undefined) return;
|
| 1433 |
+
setActiveCell(0, index);
|
| 1434 |
+
gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" });
|
| 1435 |
+
setNewRowPid(null);
|
| 1436 |
+
}, [newRowPid, displayPidToIndex, setActiveCell]);
|
| 1437 |
+
|
| 1438 |
/** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built
|
| 1439 |
* from `order` (already reconciled by useGridColumns) rather than from `config.order` so the
|
| 1440 |
* panel and the grid can never disagree about which fields exist or where they sit. */
|
|
|
|
| 1647 |
let rowsPx = HEADER_PX;
|
| 1648 |
if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight;
|
| 1649 |
else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i);
|
| 1650 |
+
// ⚠ Owner item 4 — THE GHOST ROW IS PART OF THE TABLE, and this line is what makes it
|
| 1651 |
+
// visible. glide draws its trailing row AFTER the last data row, but `displayRows` (our
|
| 1652 |
+
// rows) does not contain it, so the void started exactly where the ghost row does and
|
| 1653 |
+
// painted flat #F6F8FC straight over it. The "+" was still clickable the whole time
|
| 1654 |
+
// (`.cg-grid-void` is `pointer-events: none`), which is the worst version of this bug:
|
| 1655 |
+
// the gate went green on an affordance nobody could see. Found by READING THE SCREENSHOT
|
| 1656 |
+
// ([[ui-invisible-to-assertions]], [[finalize-visual-review-sop]]).
|
| 1657 |
+
if (isUserTable)
|
| 1658 |
+
rowsPx += typeof rowHeight === "number" ? rowHeight : rowHeight(displayRows.length);
|
| 1659 |
|
| 1660 |
/* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same
|
| 1661 |
`clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js:
|
|
|
|
| 1677 |
below: rowsPx < clientH ? rowsPx : null,
|
| 1678 |
right: colsPx < clientW ? colsPx : null,
|
| 1679 |
};
|
| 1680 |
+
}, [visibleCols, displayRows, rowHeight, gridSize, isUserTable]);
|
| 1681 |
/* ═══ end W18-B VOID (geometry) ═══ */
|
| 1682 |
|
| 1683 |
// The record drawer resolves positions against what the MODE paints: the display slice for
|
|
|
|
| 1941 |
// Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay
|
| 1942 |
// editor); the overlay store keeps its '1'-or-empty contract.
|
| 1943 |
if (value.kind === GridCellKind.Boolean && field.type === "checkbox") {
|
| 1944 |
+
patchAndRecord(row.record.pid, { [field.key]: value.data ? "1" : "" }, "a tick");
|
| 1945 |
return;
|
| 1946 |
}
|
| 1947 |
if (value.kind === GridCellKind.Uri) {
|
| 1948 |
+
patchAndRecord(row.record.pid, { [field.key]: value.data ?? "" }, "an edit");
|
| 1949 |
return;
|
| 1950 |
}
|
| 1951 |
if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) {
|
| 1952 |
+
patchAndRecord(row.record.pid, { [field.key]: value.data }, "an edit");
|
| 1953 |
}
|
| 1954 |
},
|
| 1955 |
+
[displayRows, visibleCols, fieldByKey, patchAndRecord, canEditField]
|
| 1956 |
);
|
| 1957 |
const validateCell = useCallback(
|
| 1958 |
(cell: Item): boolean => {
|
|
|
|
| 1998 |
allowedChoices: statusValues[field.key] ?? choiceOptions(field),
|
| 1999 |
});
|
| 2000 |
if (!patches) return false;
|
| 2001 |
+
// R4 — ONE stack entry for the whole paste. Cell by cell would put forty entries on the
|
| 2002 |
+
// stack for one Ctrl+V, and undoing a paste one cell at a time is not undoing a paste.
|
| 2003 |
+
patchManyAndRecord(
|
| 2004 |
+
patches.map((patch) => ({ pid: patch.pid, updates: { [field.key]: patch.value } })),
|
| 2005 |
+
"a paste"
|
| 2006 |
+
);
|
| 2007 |
// We handled the write ourselves. Returning false tells glide not to run its cell
|
| 2008 |
// renderers' generic paste path (Bubble/rating renderers cannot enforce this contract).
|
| 2009 |
return false;
|
| 2010 |
},
|
| 2011 |
+
[visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchManyAndRecord,
|
| 2012 |
gridSelection.current]
|
| 2013 |
);
|
| 2014 |
|
|
|
|
| 2949 |
...(type === "rating" ? { max: extra?.max ?? 5 } : {}),
|
| 2950 |
};
|
| 2951 |
saveField(next);
|
| 2952 |
+
/* ⭐ Owner item 15 / C-RENAME — the VALUES follow the definition.
|
| 2953 |
+
BESIDE the upsert, never instead of it, and AFTER it: the def is written optimistically
|
| 2954 |
+
here, while the rename is a host-side migration over overlay values and saved views. The
|
| 2955 |
+
order matters if the host processes the batch in order — the list must already offer
|
| 2956 |
+
"Navy" before any cell is moved onto it. */
|
| 2957 |
+
const renames = extra?.renames?.filter((r) => r.from && r.to && r.from !== r.to) ?? [];
|
| 2958 |
+
if (renames.length) {
|
| 2959 |
+
emitHostEvent({ id: eventId("choicerename"), type: "choice_rename", key, renames });
|
| 2960 |
+
// R4 — and it is undoable: Ctrl+Z emits the mapping turned around. The declared LIST is
|
| 2961 |
+
// restored by the same inverse (the host rewrites values and views back), so undo does
|
| 2962 |
+
// not need to re-send the definition.
|
| 2963 |
+
undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
| 2964 |
+
kind: "choiceRename", fieldKey: key, renames,
|
| 2965 |
+
});
|
| 2966 |
+
}
|
| 2967 |
},
|
| 2968 |
+
[fieldByKey, saveField, scope]
|
| 2969 |
);
|
| 2970 |
|
| 2971 |
/**
|
|
|
|
| 3054 |
}, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]);
|
| 3055 |
|
| 3056 |
|
| 3057 |
+
/**
|
| 3058 |
+
* ⭐ Wave-20 owner item 9 — **REMOVE FROM A COHORT WITHOUT BEING INSIDE IT.**
|
| 3059 |
+
*
|
| 3060 |
+
* "Remove from cohort" existed only under `cohortMode` (you had to open the locked view
|
| 3061 |
+
* first), while "Add to cohort" worked from any view. So the two halves of the same idea
|
| 3062 |
+
* lived on different screens: you could put a customer in a cohort from wherever you found
|
| 3063 |
+
* them, and then had to go looking for the cohort to take them out again.
|
| 3064 |
+
*
|
| 3065 |
+
* SAME EVENT, same host validation as the cohort-mode button — `cohort_remove` with the pids
|
| 3066 |
+
* intersected client-side as a courtesy. What is new is only WHICH cohort: the picker names
|
| 3067 |
+
* it, instead of it being implied by the page you are standing on.
|
| 3068 |
+
*/
|
| 3069 |
+
const removeSelectionFromList = useCallback(
|
| 3070 |
+
(cohortId: string) => {
|
| 3071 |
+
const members = cohortSets[cohortId];
|
| 3072 |
+
if (!members) return;
|
| 3073 |
+
const pids = [...selectedPids].filter((p) => members.has(p));
|
| 3074 |
+
if (!pids.length) return;
|
| 3075 |
+
emitHostEvent({ id: eventId("cohortrm"), type: "cohort_remove", cohortId, pids });
|
| 3076 |
+
setSelRemoveOpen(false);
|
| 3077 |
+
clearSelection();
|
| 3078 |
+
},
|
| 3079 |
+
[cohortSets, selectedPids, clearSelection]
|
| 3080 |
+
);
|
| 3081 |
+
|
| 3082 |
+
/**
|
| 3083 |
+
* The cohorts the checked rows can actually be removed FROM: those holding at least one of
|
| 3084 |
+
* them, and only where this viewer may edit the projected view.
|
| 3085 |
+
*
|
| 3086 |
+
* ⚠ A cohort holding NONE of the checked rows is not offered. Airtable's rule and the one R8
|
| 3087 |
+
* states for the ghost row: an affordance that can only refuse is a fake affordance — and
|
| 3088 |
+
* here it would be worse than that, because "Remove from Q3 plan" that removes nothing looks
|
| 3089 |
+
* exactly like a write that failed.
|
| 3090 |
+
*
|
| 3091 |
+
* The edit test is the COURTESY half (`mayEditView`, like every other client-side permission
|
| 3092 |
+
* check on this surface); the host re-validates ownership and pool on the event regardless.
|
| 3093 |
+
* A cohort with no projected view is still offered — the host owns that verdict, and hiding
|
| 3094 |
+
* it here would silently drop sets the reader can see in their own rail.
|
| 3095 |
+
*/
|
| 3096 |
+
const removableLists = useMemo(() => {
|
| 3097 |
+
if (cohortMode || selectedPids.size === 0) return [];
|
| 3098 |
+
const out: { id: string; name: string; hits: number }[] = [];
|
| 3099 |
+
for (const l of lists) {
|
| 3100 |
+
const members = cohortSets[l.id];
|
| 3101 |
+
if (!members) continue;
|
| 3102 |
+
let hits = 0;
|
| 3103 |
+
for (const pid of selectedPids) if (members.has(pid)) hits += 1;
|
| 3104 |
+
if (!hits) continue;
|
| 3105 |
+
const projected = views.find((v) => v.id === l.id);
|
| 3106 |
+
if (projected && !mayEditView(projected, viewer)) continue;
|
| 3107 |
+
out.push({ id: l.id, name: l.name, hits });
|
| 3108 |
+
}
|
| 3109 |
+
return out;
|
| 3110 |
+
}, [cohortMode, selectedPids, lists, cohortSets, views, viewer]);
|
| 3111 |
+
|
| 3112 |
/** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */
|
| 3113 |
const addCustomersToCohort = useCallback(() => {
|
| 3114 |
if (!activeCohortId || addCustPicked.size === 0) return;
|
|
|
|
| 3377 |
const onKanbanMove = useCallback(
|
| 3378 |
(pid: number, value: string) => {
|
| 3379 |
if (!kanbanField) return;
|
| 3380 |
+
patchAndRecord(pid, { [kanbanField.key]: value }, "a card move");
|
| 3381 |
},
|
| 3382 |
+
[kanbanField, patchAndRecord]
|
| 3383 |
);
|
| 3384 |
const onListToggleGroup = useCallback((groupKey: string) => {
|
| 3385 |
setCollapsed((current) => {
|
|
|
|
| 3497 |
? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null
|
| 3498 |
: null;
|
| 3499 |
const menuIsFiltered = menuField
|
| 3500 |
+
? treeNamesField(config.filters, menuField.key, measureCols)
|
| 3501 |
: false;
|
| 3502 |
// Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)?
|
| 3503 |
const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
|
|
|
|
| 3810 |
onCellActivated={onCellActivated}
|
| 3811 |
onCellEdited={onCellEdited}
|
| 3812 |
onPaste={onGridPaste}
|
| 3813 |
+
// R4 — Backspace/Delete over a selection is ONE undoable action. Returning
|
| 3814 |
+
// `false` from here takes the write over from glide's per-cell path.
|
| 3815 |
+
onDelete={onGridDelete}
|
| 3816 |
validateCell={validateCell}
|
| 3817 |
onColumnResize={onColumnResize}
|
| 3818 |
onColumnMoved={onColumnMoved}
|
|
|
|
| 3832 |
customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
|
| 3833 |
headerIcons={HEADER_ICONS}
|
| 3834 |
rightElement={
|
| 3835 |
+
// Wave-6 item 4 — the "+" of the header row: the create-field form,
|
| 3836 |
+
// insert-at-end.
|
| 3837 |
+
//
|
| 3838 |
+
// ⚠ Wave-20 owner item 5 UNPINNED it. It used to be `sticky: true` ("so it
|
| 3839 |
+
// stays visible however far the columns scroll"), which parked it against the
|
| 3840 |
+
// right edge of the WINDOW — on a four-column table that is half a screen of
|
| 3841 |
+
// white between the last field and the control that adds the next one, and it
|
| 3842 |
+
// reads as page chrome rather than as part of the table. It now sits
|
| 3843 |
+
// immediately after the last column, where Airtable puts it.
|
| 3844 |
+
//
|
| 3845 |
+
// TWO changes are needed and neither works alone: `sticky:false` stops glide
|
| 3846 |
+
// pinning the wrapper (`infinite-scroller.js:204` sets `right` only when
|
| 3847 |
+
// sticky), and the CSS region kills `.dvn-spacer`'s `flex-grow: 1`, which
|
| 3848 |
+
// would otherwise expand to fill the scroller and push the button right back
|
| 3849 |
+
// to the edge. Consequence, accepted: on a table wider than the viewport the
|
| 3850 |
+
// "+" is off-screen until you scroll to the end of the columns — which is
|
| 3851 |
+
// where the new column is going to land anyway.
|
| 3852 |
<button
|
| 3853 |
type="button"
|
| 3854 |
className="cg-add-field-btn"
|
|
|
|
| 3869 |
+
|
| 3870 |
</button>
|
| 3871 |
}
|
| 3872 |
+
rightElementProps={{ sticky: false, fill: false }}
|
| 3873 |
+
// Owner item 4 (R8) — the trailing "+" row, USER DATABASES ONLY. Passing
|
| 3874 |
+
// `onRowAppended` is what makes glide paint the ghost row at all, so the
|
| 3875 |
+
// affordance exists exactly where a POST can succeed. See `appendRow`.
|
| 3876 |
+
onRowAppended={isUserTable ? appendRow : undefined}
|
| 3877 |
+
trailingRowOptions={
|
| 3878 |
+
isUserTable
|
| 3879 |
+
? {
|
| 3880 |
+
// The hint sits in the identity column (targetColumn 0), which is the
|
| 3881 |
+
// one a new record is named in — the same cell the cursor lands on.
|
| 3882 |
+
hint: "New record",
|
| 3883 |
+
sticky: false,
|
| 3884 |
+
targetColumn: 0,
|
| 3885 |
+
}
|
| 3886 |
+
: undefined
|
| 3887 |
+
}
|
| 3888 |
/>
|
| 3889 |
{/* ═══ W18-B VOID ═══ (wave 18, owner item 1b / ruling R12) — the two rectangles.
|
| 3890 |
|
|
|
|
| 4168 |
Add to cohort
|
| 4169 |
</button>
|
| 4170 |
)}
|
| 4171 |
+
{/* ⭐ Owner item 9 — the counterpart, and it renders ONLY when it can do
|
| 4172 |
+
something: the checked rows have to be in a cohort this viewer may edit for
|
| 4173 |
+
"Remove from cohort…" to mean anything. Same bar, same selection, opposite
|
| 4174 |
+
direction — the ellipsis says a picker follows, because unlike cohort mode
|
| 4175 |
+
the target is not implied by where you are standing. */}
|
| 4176 |
+
{!cohortMode && removableLists.length > 0 && (
|
| 4177 |
+
<button
|
| 4178 |
+
type="button"
|
| 4179 |
+
className="cg-btn cg-selbar-remove-pick"
|
| 4180 |
+
style={ONE_LINE}
|
| 4181 |
+
ref={selRemoveRef}
|
| 4182 |
+
onClick={() => setSelRemoveOpen((open) => !open)}
|
| 4183 |
+
aria-expanded={selRemoveOpen}
|
| 4184 |
+
aria-haspopup="dialog"
|
| 4185 |
+
>
|
| 4186 |
+
Remove from cohort…
|
| 4187 |
+
</button>
|
| 4188 |
+
)}
|
| 4189 |
<button type="button" className="cg-btn" style={ONE_LINE}
|
| 4190 |
onClick={clearSelection}>
|
| 4191 |
Clear
|
|
|
|
| 4326 |
onClearFilter={() =>
|
| 4327 |
updateConfig({
|
| 4328 |
...config,
|
| 4329 |
+
filters: dropFieldFromTree(config.filters, menuField.key, measureCols),
|
| 4330 |
})
|
| 4331 |
}
|
| 4332 |
onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
|
|
|
|
| 4428 |
</AnchoredOverlay>
|
| 4429 |
)}
|
| 4430 |
|
| 4431 |
+
{/* ⭐ Owner item 9 — the remove picker. Every row states how many of the CHECKED rows it
|
| 4432 |
+
would take out ("3 of 4"), because the selection and the cohort are two different sets
|
| 4433 |
+
and the difference is the whole reason this needs a picker rather than a button. */}
|
| 4434 |
+
{selRemoveOpen && selRemoveRef.current && removableLists.length > 0 && (
|
| 4435 |
+
<AnchoredOverlay
|
| 4436 |
+
anchor={selRemoveRef.current}
|
| 4437 |
+
className="cg-pop cg-add-list-pop"
|
| 4438 |
+
placement="bottom-start"
|
| 4439 |
+
role="dialog"
|
| 4440 |
+
ariaLabel="Remove selected customers from a locked view"
|
| 4441 |
+
onDismiss={() => setSelRemoveOpen(false)}
|
| 4442 |
+
dataKind="selection-remove-from-list"
|
| 4443 |
+
>
|
| 4444 |
+
<div className="cg-pop-title">Remove from locked view</div>
|
| 4445 |
+
<div className="cg-pop-note">
|
| 4446 |
+
Only the locked views holding one of the {selectedPids.size.toLocaleString()} checked
|
| 4447 |
+
customer{selectedPids.size === 1 ? "" : "s"} are listed. The records themselves are
|
| 4448 |
+
not touched.
|
| 4449 |
+
</div>
|
| 4450 |
+
{removableLists.map((l) => (
|
| 4451 |
+
<button
|
| 4452 |
+
type="button"
|
| 4453 |
+
key={l.id}
|
| 4454 |
+
className="cg-pick-row"
|
| 4455 |
+
onClick={() => removeSelectionFromList(l.id)}
|
| 4456 |
+
>
|
| 4457 |
+
{l.name}
|
| 4458 |
+
<span className="cg-pick-hint">
|
| 4459 |
+
{l.hits.toLocaleString()} of {selectedPids.size.toLocaleString()}
|
| 4460 |
+
</span>
|
| 4461 |
+
</button>
|
| 4462 |
+
))}
|
| 4463 |
+
</AnchoredOverlay>
|
| 4464 |
+
)}
|
| 4465 |
+
|
| 4466 |
{/* Cohort mode's "+ Add customers" picker (item 2c): search the POOL, tick many, confirm
|
| 4467 |
once. Candidates exclude current members; picks ACCUMULATE across searches, so the
|
| 4468 |
confirm button carries the running total. The 50-row render cap is stated beside the
|
|
|
|
| 4603 |
}
|
| 4604 |
aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
| 4605 |
onClick={() => {
|
| 4606 |
+
patchAndRecord(picker.pid, { [pickerField.key]: String(n) }, "a rating");
|
| 4607 |
closePicker();
|
| 4608 |
}}
|
| 4609 |
>
|
|
|
|
| 4641 |
const next = on
|
| 4642 |
? pickerParts.filter((p) => p !== choice)
|
| 4643 |
: [...pickerParts, choice];
|
| 4644 |
+
patchAndRecord(picker.pid, {
|
| 4645 |
[pickerField.key]: next.join(","),
|
| 4646 |
+
}, "a choice");
|
| 4647 |
return;
|
| 4648 |
}
|
| 4649 |
+
patchAndRecord(picker.pid, { [pickerField.key]: choice }, "a choice");
|
| 4650 |
closePicker();
|
| 4651 |
}}
|
| 4652 |
>
|
|
|
|
| 4736 |
// (the querySelector takes the FIRST match, so a real choice row wins).
|
| 4737 |
data-overlay-autofocus={pickerChoices.length === 0 ? true : undefined}
|
| 4738 |
onClick={() => {
|
| 4739 |
+
patchAndRecord(picker.pid, { [pickerField.key]: "" }, "a choice");
|
| 4740 |
closePicker();
|
| 4741 |
}}
|
| 4742 |
>
|
|
|
|
| 4810 |
[detailPid]: { ...current[detailPid], [key]: value },
|
| 4811 |
}))
|
| 4812 |
}
|
| 4813 |
+
onNotesCommit={(key, value) => patchAndRecord(detailPid, { [key]: value })}
|
| 4814 |
viewer={viewer}
|
| 4815 |
// Wave-14 close-out stitch (HOST): TSREC's two light-switch props, unwired when
|
| 4816 |
// the GRID session ended — item 9's picker vocabulary + item 11's photos. Same
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -37,7 +37,14 @@ import {
|
|
| 37 |
MODE_TONE,
|
| 38 |
} from "./iconShapes";
|
| 39 |
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
|
| 40 |
-
import { groupByFolder } from "./folders";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
interface ViewSidebarProps {
|
| 43 |
views: SavedView[];
|
|
@@ -99,6 +106,16 @@ interface ViewSidebarProps {
|
|
| 99 |
onFolderDelete?: (folderId: string) => void;
|
| 100 |
onFolderDuplicate?: (folderId: string) => void;
|
| 101 |
onItemMove?: (viewId: string, folderId: string | null) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
/**
|
| 103 |
* C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller
|
| 104 |
* owns the arithmetic (it holds the engine); the rail owns the confirm.
|
|
@@ -151,6 +168,7 @@ export default function ViewSidebar({
|
|
| 151 |
onFolderDelete,
|
| 152 |
onFolderDuplicate,
|
| 153 |
onItemMove,
|
|
|
|
| 154 |
folderAddPreview,
|
| 155 |
onFolderAddToList,
|
| 156 |
viewer,
|
|
@@ -257,6 +275,32 @@ export default function ViewSidebar({
|
|
| 257 |
const shownViews = viewNeedle
|
| 258 |
? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
|
| 259 |
: views;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
|
| 261 |
const groups = groupByFolder(
|
| 262 |
shownViews,
|
|
@@ -265,15 +309,47 @@ export default function ViewSidebar({
|
|
| 265 |
);
|
| 266 |
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
|
| 267 |
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
const dropHandlers = (folderId: string | null) =>
|
| 269 |
-
foldersOn
|
| 270 |
? {
|
| 271 |
onDragOver: (e: React.DragEvent) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
e.preventDefault();
|
| 273 |
setDropTarget(folderId ?? "__root__");
|
| 274 |
},
|
| 275 |
-
onDragLeave: () =>
|
|
|
|
|
|
|
|
|
|
| 276 |
onDrop: (e: React.DragEvent) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
e.preventDefault();
|
| 278 |
setDropTarget(null);
|
| 279 |
const id = e.dataTransfer.getData("text/plain");
|
|
@@ -299,6 +375,23 @@ export default function ViewSidebar({
|
|
| 299 |
setPermEdit("personal");
|
| 300 |
setPermUsers([]);
|
| 301 |
setCreating(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
};
|
| 303 |
|
| 304 |
const createFolder = () => {
|
|
@@ -312,10 +405,18 @@ export default function ViewSidebar({
|
|
| 312 |
setCreatingFolder(false);
|
| 313 |
};
|
| 314 |
|
| 315 |
-
/**
|
| 316 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
const startView = (mode: DisplayMode) => {
|
| 318 |
-
setCreateMenu(null);
|
| 319 |
setCreatingFolder(false);
|
| 320 |
setCreating(mode);
|
| 321 |
};
|
|
@@ -325,6 +426,28 @@ export default function ViewSidebar({
|
|
| 325 |
setCreatingFolder(true);
|
| 326 |
};
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
|
| 329 |
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
| 330 |
const items = [
|
|
@@ -403,9 +526,24 @@ export default function ViewSidebar({
|
|
| 403 |
className="cg-link-btn cg-create-btn"
|
| 404 |
aria-haspopup="menu"
|
| 405 |
aria-expanded={!!createMenu}
|
| 406 |
-
onClick={(event) =>
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
>
|
| 410 |
<span className="cg-create-icon" aria-hidden="true">+</span>
|
| 411 |
<span className="cg-create-label">Create new…</span>
|
|
@@ -444,97 +582,10 @@ export default function ViewSidebar({
|
|
| 444 |
/>
|
| 445 |
</div>
|
| 446 |
|
| 447 |
-
{
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
as unrelated and "Calendar" silently becomes a fact you cannot see. */}
|
| 452 |
-
New {MODE_LABELS[creating].toLowerCase()} view
|
| 453 |
-
</label>
|
| 454 |
-
<input
|
| 455 |
-
id="cg-new-view"
|
| 456 |
-
className="cg-input"
|
| 457 |
-
autoFocus
|
| 458 |
-
value={name}
|
| 459 |
-
placeholder="e.g. Florida at risk"
|
| 460 |
-
onChange={(event) => setName(event.target.value)}
|
| 461 |
-
onKeyDown={(event) => {
|
| 462 |
-
if (event.key === "Enter") create();
|
| 463 |
-
if (event.key === "Escape") setCreating(null);
|
| 464 |
-
}}
|
| 465 |
-
/>
|
| 466 |
-
{/* I17 (C4) — the owner's prompt ORDER: type (picked in the flyout, named above)
|
| 467 |
-
→ who can edit → the user picker, and the picker only when it applies. */}
|
| 468 |
-
<div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
|
| 469 |
-
<span className="cg-perm-title">Who can edit</span>
|
| 470 |
-
{VIEW_EDIT_MODES.map((m) => (
|
| 471 |
-
<label key={m} className="cg-radio-row cg-perm-row">
|
| 472 |
-
<input
|
| 473 |
-
type="radio"
|
| 474 |
-
name="cg-view-perm"
|
| 475 |
-
checked={permEdit === m}
|
| 476 |
-
onChange={() => setPermEdit(m)}
|
| 477 |
-
/>
|
| 478 |
-
<span className="cg-perm-label">
|
| 479 |
-
{VIEW_EDIT_LABELS[m]}
|
| 480 |
-
<span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
|
| 481 |
-
</span>
|
| 482 |
-
</label>
|
| 483 |
-
))}
|
| 484 |
-
{permEdit === "users" && (
|
| 485 |
-
<div className="cg-perm-users">
|
| 486 |
-
{userOptions.length === 0 ? (
|
| 487 |
-
// Never a silent empty box: an empty grant collapses to Personal host-side,
|
| 488 |
-
// so say that rather than letting the user think they shared it.
|
| 489 |
-
<span className="cg-perm-empty">
|
| 490 |
-
No other accounts to pick — this will save as Personal.
|
| 491 |
-
</span>
|
| 492 |
-
) : (
|
| 493 |
-
userOptions.map((u) => (
|
| 494 |
-
<label key={u} className="cg-perm-user">
|
| 495 |
-
<input
|
| 496 |
-
type="checkbox"
|
| 497 |
-
checked={permUsers.includes(u)}
|
| 498 |
-
onChange={(e) =>
|
| 499 |
-
setPermUsers((cur) =>
|
| 500 |
-
e.target.checked
|
| 501 |
-
? [...cur, u].slice(0, MAX_VIEW_USERS)
|
| 502 |
-
: cur.filter((x) => x !== u)
|
| 503 |
-
)
|
| 504 |
-
}
|
| 505 |
-
/>
|
| 506 |
-
<span>{u}</span>
|
| 507 |
-
</label>
|
| 508 |
-
))
|
| 509 |
-
)}
|
| 510 |
-
{userOptions.length > 0 && permUsers.length === 0 && (
|
| 511 |
-
<span className="cg-perm-empty">
|
| 512 |
-
Pick at least one person, or this saves as Personal.
|
| 513 |
-
</span>
|
| 514 |
-
)}
|
| 515 |
-
</div>
|
| 516 |
-
)}
|
| 517 |
-
</div>
|
| 518 |
-
<div className="cg-form-actions">
|
| 519 |
-
<button
|
| 520 |
-
type="button"
|
| 521 |
-
className="cg-btn cg-btn--primary"
|
| 522 |
-
onClick={create}
|
| 523 |
-
disabled={!name.trim()}
|
| 524 |
-
>
|
| 525 |
-
Create
|
| 526 |
-
</button>
|
| 527 |
-
<button
|
| 528 |
-
type="button"
|
| 529 |
-
className="cg-btn"
|
| 530 |
-
onClick={() => setCreating(null)}
|
| 531 |
-
>
|
| 532 |
-
Cancel
|
| 533 |
-
</button>
|
| 534 |
-
</div>
|
| 535 |
-
</div>
|
| 536 |
-
)}
|
| 537 |
-
|
| 538 |
{/* I15 — the folder form: name + the icon the folder will wear. */}
|
| 539 |
{foldersOn && onFolderCreate && creatingFolder && (
|
| 540 |
<div className="cg-view-create cg-create-form cg-fold-form">
|
|
@@ -613,38 +664,144 @@ export default function ViewSidebar({
|
|
| 613 |
anchor={createMenu}
|
| 614 |
className="cg-view-menu cg-create-flyout"
|
| 615 |
placement="right-start"
|
| 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 |
-
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
</>
|
| 649 |
)}
|
| 650 |
</AnchoredOverlay>
|
|
@@ -662,12 +819,42 @@ export default function ViewSidebar({
|
|
| 662 |
key={gid ?? "__root__"}
|
| 663 |
className={
|
| 664 |
(isRoot ? "cg-fold-root" : "cg-fold") +
|
| 665 |
-
(dropTarget === (gid ?? "__root__") ? " is-drop" : "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
}
|
| 667 |
{...dropHandlers(gid)}
|
| 668 |
>
|
| 669 |
{group.folder && (
|
| 670 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
<button
|
| 672 |
type="button"
|
| 673 |
className="cg-fold-toggle"
|
|
@@ -710,8 +897,28 @@ export default function ViewSidebar({
|
|
| 710 |
) : (
|
| 711 |
<span className="cg-fold-name">{group.folder.name}</span>
|
| 712 |
)}
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
<button
|
| 716 |
type="button"
|
| 717 |
className="cg-view-more"
|
|
@@ -729,6 +936,7 @@ export default function ViewSidebar({
|
|
| 729 |
>
|
| 730 |
···
|
| 731 |
</button>
|
|
|
|
| 732 |
</div>
|
| 733 |
)}
|
| 734 |
{/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
|
|
@@ -818,6 +1026,35 @@ export default function ViewSidebar({
|
|
| 818 |
so explicitly), so most rows have exactly one reason. When a row has both,
|
| 819 |
the row-set meaning leads and the title carries the other — never two
|
| 820 |
padlocks side by side, which would read as a bug. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
{(view.kind === "locked" || isModeFrozen(view)) && (
|
| 822 |
<span
|
| 823 |
className="cg-view-lock"
|
|
@@ -902,7 +1139,15 @@ export default function ViewSidebar({
|
|
| 902 |
The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
|
| 903 |
</div>
|
| 904 |
|
| 905 |
-
{/* I11c — the folder "…" menu. Rename · Add to cohort · Duplicate · Delete.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
{folderMenu && folderMenuF && (
|
| 907 |
<AnchoredOverlay
|
| 908 |
anchor={folderMenu.anchor}
|
|
@@ -925,7 +1170,7 @@ export default function ViewSidebar({
|
|
| 925 |
setFolderMenu(null);
|
| 926 |
}}
|
| 927 |
>
|
| 928 |
-
Rename
|
| 929 |
</button>
|
| 930 |
{folderAddPreview && onFolderAddToList && (
|
| 931 |
<button
|
|
@@ -939,7 +1184,7 @@ export default function ViewSidebar({
|
|
| 939 |
setFolderMenu(null);
|
| 940 |
}}
|
| 941 |
>
|
| 942 |
-
Add to cohort…
|
| 943 |
</button>
|
| 944 |
)}
|
| 945 |
{onFolderDuplicate && (
|
|
@@ -952,14 +1197,30 @@ export default function ViewSidebar({
|
|
| 952 |
setFolderMenu(null);
|
| 953 |
}}
|
| 954 |
>
|
| 955 |
-
Duplicate folder and its views
|
| 956 |
</button>
|
| 957 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 958 |
{onFolderDelete && (
|
| 959 |
<button
|
| 960 |
type="button"
|
| 961 |
role="menuitem"
|
| 962 |
-
className={
|
|
|
|
|
|
|
|
|
|
| 963 |
onClick={() => {
|
| 964 |
if (confirmDelete !== folderMenuF.id) {
|
| 965 |
setConfirmDelete(folderMenuF.id);
|
|
@@ -970,9 +1231,18 @@ export default function ViewSidebar({
|
|
| 970 |
setFolderMenu(null);
|
| 971 |
}}
|
| 972 |
>
|
| 973 |
-
{
|
| 974 |
-
|
| 975 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
</button>
|
| 977 |
)}
|
| 978 |
</AnchoredOverlay>
|
|
@@ -1170,6 +1440,44 @@ export default function ViewSidebar({
|
|
| 1170 |
>
|
| 1171 |
<MenuLabel icon="duplicate" text="Duplicate" />
|
| 1172 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
{onAddToList && (
|
| 1174 |
<button
|
| 1175 |
type="button"
|
|
|
|
| 37 |
MODE_TONE,
|
| 38 |
} from "./iconShapes";
|
| 39 |
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
|
| 40 |
+
import { groupByFolder, reorderFolderIds } from "./folders";
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* WAVE 20 item 18 (C-SHARE) — the system folder every view shared WITH you lands in
|
| 44 |
+
* until you file it somewhere of your own. The id is the CONTRACT's, not this file's
|
| 45 |
+
* invention, and it is the only folder id this component ever recognises by name.
|
| 46 |
+
*/
|
| 47 |
+
const SHARED_FOLDER_ID = "__shared__";
|
| 48 |
|
| 49 |
interface ViewSidebarProps {
|
| 50 |
views: SavedView[];
|
|
|
|
| 106 |
onFolderDelete?: (folderId: string) => void;
|
| 107 |
onFolderDuplicate?: (folderId: string) => void;
|
| 108 |
onItemMove?: (viewId: string, folderId: string | null) => void;
|
| 109 |
+
/**
|
| 110 |
+
* WAVE 20 item 19 (C-FOLDER-REORDER) — the folders, in the order the user just dragged
|
| 111 |
+
* them into. The FULL list every time, never a delta: a partial order cannot say where an
|
| 112 |
+
* unnamed folder went, and the host has to be able to stamp `order` on all of them.
|
| 113 |
+
*
|
| 114 |
+
* Optional like every other folder handler here: a caller that does not pass it gets a
|
| 115 |
+
* rail with no folder drag at all, rather than a grip that lifts a folder and drops it
|
| 116 |
+
* back — the posture this whole component takes toward an action nobody can honour.
|
| 117 |
+
*/
|
| 118 |
+
onFolderReorder?: (order: string[]) => void;
|
| 119 |
/**
|
| 120 |
* C4 as AMENDED 2026-07-28 — the folder-level bulk "Add to cohort". The caller
|
| 121 |
* owns the arithmetic (it holds the engine); the rail owns the confirm.
|
|
|
|
| 168 |
onFolderDelete,
|
| 169 |
onFolderDuplicate,
|
| 170 |
onItemMove,
|
| 171 |
+
onFolderReorder,
|
| 172 |
folderAddPreview,
|
| 173 |
onFolderAddToList,
|
| 174 |
viewer,
|
|
|
|
| 275 |
const shownViews = viewNeedle
|
| 276 |
? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
|
| 277 |
: views;
|
| 278 |
+
// ── WAVE 20 item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder the rail ──────
|
| 279 |
+
//
|
| 280 |
+
// ⚠ A PRIVATE dataTransfer MIME, and it is not decoration. The VIEW drag two blocks down
|
| 281 |
+
// carries `text/plain`, and the folder groups already accept that drop ("file this view
|
| 282 |
+
// into me"). Sharing one channel would make every folder drag look like a view drop to
|
| 283 |
+
// the handler that fires first — so the two drags are told apart by TYPE, the same
|
| 284 |
+
// discipline the shell nav's own drag uses, and a drop of this type anywhere
|
| 285 |
+
// text-editable types nothing.
|
| 286 |
+
const FOLD_DRAG_TYPE = "application/x-loopable-fold";
|
| 287 |
+
/** The folder being dragged, and the group its insertion line is currently drawn above. */
|
| 288 |
+
const [foldDrag, setFoldDrag] = useState<string | null>(null);
|
| 289 |
+
const [foldOver, setFoldOver] = useState<string | null>(null);
|
| 290 |
+
const foldsReorderable = !!folders && !!onFolderReorder;
|
| 291 |
+
/**
|
| 292 |
+
* Land `draggedId` before `beforeId` (null = last) and emit the new order. The arithmetic
|
| 293 |
+
* lives in `folders.ts` so a gate can run it under node — the handler around it is DOM.
|
| 294 |
+
*
|
| 295 |
+
* ⚠ The order is computed from the `folders` PROP, never from the rendered groups: a live
|
| 296 |
+
* "Find a view" query hides folders with no matches, and an order derived from what is on
|
| 297 |
+
* screen would silently drop the hidden ones out of the sequence.
|
| 298 |
+
*/
|
| 299 |
+
const reorderFolders = (draggedId: string, beforeId: string | null) => {
|
| 300 |
+
const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId);
|
| 301 |
+
// `null` is "nothing to say" — an unknown id, or a drop that changed nothing.
|
| 302 |
+
if (next) onFolderReorder?.(next);
|
| 303 |
+
};
|
| 304 |
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
|
| 305 |
const groups = groupByFolder(
|
| 306 |
shownViews,
|
|
|
|
| 309 |
);
|
| 310 |
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
|
| 311 |
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
|
| 312 |
+
/**
|
| 313 |
+
* ONE pair of drop handlers per group, serving TWO drags — a view being filed into this
|
| 314 |
+
* folder (`text/plain`) and, since wave 20 item 19, a folder being dropped in front of
|
| 315 |
+
* this one (`FOLD_DRAG_TYPE`). They must be one pair rather than two spreads: a second
|
| 316 |
+
* `onDragOver`/`onDrop` on the same element replaces the first, silently, and the drag
|
| 317 |
+
* that lost would simply stop working.
|
| 318 |
+
*
|
| 319 |
+
* The type is read BEFORE anything else, so a folder drag never paints the "file a view
|
| 320 |
+
* in here" fill and a view drag never draws the insertion rule.
|
| 321 |
+
*/
|
| 322 |
const dropHandlers = (folderId: string | null) =>
|
| 323 |
+
foldersOn || foldsReorderable
|
| 324 |
? {
|
| 325 |
onDragOver: (e: React.DragEvent) => {
|
| 326 |
+
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
|
| 327 |
+
if (!foldsReorderable) return;
|
| 328 |
+
e.preventDefault();
|
| 329 |
+
e.dataTransfer.dropEffect = "move";
|
| 330 |
+
setFoldOver(folderId ?? "__root__");
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
if (!foldersOn) return;
|
| 334 |
e.preventDefault();
|
| 335 |
setDropTarget(folderId ?? "__root__");
|
| 336 |
},
|
| 337 |
+
onDragLeave: () => {
|
| 338 |
+
setDropTarget(null);
|
| 339 |
+
setFoldOver(null);
|
| 340 |
+
},
|
| 341 |
onDrop: (e: React.DragEvent) => {
|
| 342 |
+
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
|
| 343 |
+
const dragged = e.dataTransfer.getData(FOLD_DRAG_TYPE);
|
| 344 |
+
setFoldOver(null);
|
| 345 |
+
setFoldDrag(null);
|
| 346 |
+
if (!foldsReorderable || !dragged) return;
|
| 347 |
+
e.preventDefault();
|
| 348 |
+
e.stopPropagation();
|
| 349 |
+
reorderFolders(dragged, folderId);
|
| 350 |
+
return;
|
| 351 |
+
}
|
| 352 |
+
if (!foldersOn) return;
|
| 353 |
e.preventDefault();
|
| 354 |
setDropTarget(null);
|
| 355 |
const id = e.dataTransfer.getData("text/plain");
|
|
|
|
| 375 |
setPermEdit("personal");
|
| 376 |
setPermUsers([]);
|
| 377 |
setCreating(null);
|
| 378 |
+
// WAVE 20 item 20 — the two steps share ONE panel, so the anchor outlives step 1 and
|
| 379 |
+
// has to be released here. Left set, the flyout would spring back to the type chooser
|
| 380 |
+
// the instant the view was created.
|
| 381 |
+
setCreateMenu(null);
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
/** Item 20 — abandon the whole flyout, from either step. Escape, an outside click and
|
| 385 |
+
* Cancel all mean the same thing ("I am not creating anything"), so they call one
|
| 386 |
+
* function rather than three combinations of setters — and abandoning drops the DRAFT
|
| 387 |
+
* too, or the next "+ Create new…" would hand back a half-filled form from a decision
|
| 388 |
+
* the user already walked away from. */
|
| 389 |
+
const closeCreate = () => {
|
| 390 |
+
setCreating(null);
|
| 391 |
+
setCreateMenu(null);
|
| 392 |
+
setName("");
|
| 393 |
+
setPermEdit("personal");
|
| 394 |
+
setPermUsers([]);
|
| 395 |
};
|
| 396 |
|
| 397 |
const createFolder = () => {
|
|
|
|
| 405 |
setCreatingFolder(false);
|
| 406 |
};
|
| 407 |
|
| 408 |
+
/**
|
| 409 |
+
* WAVE 20 item 20 — the view door KEEPS the anchor, because the name/permissions step
|
| 410 |
+
* now renders in the SAME anchored box the type chooser used. The old comment ("close
|
| 411 |
+
* the flyout first, or the menu sits on top of the input") described the bug the owner
|
| 412 |
+
* reported from the other side: the two steps of one action were drawn in two different
|
| 413 |
+
* places, one beside the rail and one inside it, so step 2 read as an unrelated form.
|
| 414 |
+
* One panel, two contents, one geometry.
|
| 415 |
+
*
|
| 416 |
+
* The FOLDER door still closes it: that path is a single step and has no second panel
|
| 417 |
+
* to disagree with. Moving it too would be a change the ruling does not ask for.
|
| 418 |
+
*/
|
| 419 |
const startView = (mode: DisplayMode) => {
|
|
|
|
| 420 |
setCreatingFolder(false);
|
| 421 |
setCreating(mode);
|
| 422 |
};
|
|
|
|
| 426 |
setCreatingFolder(true);
|
| 427 |
};
|
| 428 |
|
| 429 |
+
/**
|
| 430 |
+
* WAVE 20 items 18/23/26 (R10, C-SHARE) — open the access editor for a view or a
|
| 431 |
+
* folder.
|
| 432 |
+
*
|
| 433 |
+
* ⚠ A WINDOW EVENT, NOT A PROP, and not for convenience. The dialog is the SHELL's
|
| 434 |
+
* (one editor for views, folders and databases — R10 asks for one vocabulary), and
|
| 435 |
+
* this tree is host-neutral: `customer-grid/**` must not import a shell. The frame
|
| 436 |
+
* already listens on this channel for toasts and staleness; this is one more note on
|
| 437 |
+
* it. The literal is spelled once here and once in `shell/shareModel.ts`, which is
|
| 438 |
+
* where it becomes a shared constant when S3 publishes it in `apiContract.ts`
|
| 439 |
+
* (amendment A-S4-4).
|
| 440 |
+
*
|
| 441 |
+
* ⛔ It degrades to NOTHING when no shell is listening (the Streamlit-era bare grid):
|
| 442 |
+
* a dispatched event nobody hears opens no dialog and throws no error — which is the
|
| 443 |
+
* right failure for a frame-level surface reached from a rail.
|
| 444 |
+
*/
|
| 445 |
+
const openShare = (kind: "view" | "folder", id: string, label: string) => {
|
| 446 |
+
window.dispatchEvent(
|
| 447 |
+
new CustomEvent("aios:share-open", { detail: { kind, id, label } })
|
| 448 |
+
);
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
|
| 452 |
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
| 453 |
const items = [
|
|
|
|
| 526 |
className="cg-link-btn cg-create-btn"
|
| 527 |
aria-haspopup="menu"
|
| 528 |
aria-expanded={!!createMenu}
|
| 529 |
+
onClick={(event) => {
|
| 530 |
+
// ⛔ THE ANCHOR IS HOISTED OUT OF THE UPDATER — the same line that took the whole
|
| 531 |
+
// app white from the view row's "…" (see the long note at that button). React
|
| 532 |
+
// nulls `event.currentTarget` when the handler returns and may re-invoke a
|
| 533 |
+
// functional updater afterwards, so `setCreateMenu(cur => … event.currentTarget)`
|
| 534 |
+
// can store null. Item 20 made this panel outlive a single click — it now carries
|
| 535 |
+
// the name step too — so a randomly-nulled anchor would close a form mid-typing
|
| 536 |
+
// rather than merely mis-place a menu.
|
| 537 |
+
const anchor = event.currentTarget;
|
| 538 |
+
if (createMenu) {
|
| 539 |
+
closeCreate();
|
| 540 |
+
return;
|
| 541 |
+
}
|
| 542 |
+
// Every open starts at the type chooser: `creating` left set by an abandoned
|
| 543 |
+
// flyout would re-enter at step 2 for a kind nobody just picked.
|
| 544 |
+
setCreating(null);
|
| 545 |
+
setCreateMenu(anchor);
|
| 546 |
+
}}
|
| 547 |
>
|
| 548 |
<span className="cg-create-icon" aria-hidden="true">+</span>
|
| 549 |
<span className="cg-create-label">Create new…</span>
|
|
|
|
| 582 |
/>
|
| 583 |
</div>
|
| 584 |
|
| 585 |
+
{/* ⛔ WAVE 20 item 20 — THE CREATE FORM NO LONGER RENDERS HERE. It is the flyout's
|
| 586 |
+
second step, inside the same `AnchoredOverlay` (below, next to the type list it
|
| 587 |
+
follows). This comment stands in for the moved markup because the move IS the
|
| 588 |
+
item: two steps of one action that used to appear in two different places. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
{/* I15 — the folder form: name + the icon the folder will wear. */}
|
| 590 |
{foldersOn && onFolderCreate && creatingFolder && (
|
| 591 |
<div className="cg-view-create cg-create-form cg-fold-form">
|
|
|
|
| 664 |
anchor={createMenu}
|
| 665 |
className="cg-view-menu cg-create-flyout"
|
| 666 |
placement="right-start"
|
| 667 |
+
// WAVE 20 item 20 — ONE panel, two contents. A menu of choices and a form are
|
| 668 |
+
// different KINDS of thing to a screen reader even when they are one box to the
|
| 669 |
+
// eye, so the role and the label follow the step rather than being frozen at the
|
| 670 |
+
// panel's first purpose.
|
| 671 |
+
role={creating ? "dialog" : "menu"}
|
| 672 |
+
ariaLabel={
|
| 673 |
+
creating ? `New ${MODE_LABELS[creating].toLowerCase()} view` : "Create new"
|
| 674 |
+
}
|
| 675 |
+
onDismiss={closeCreate}
|
| 676 |
+
// ⛔ THE ARROW-KEY HANDLER IS STEP 1's ONLY. `onMenuKeyDown` swallows
|
| 677 |
+
// ArrowUp/ArrowDown to walk `[role=menuitem]`; over a text input that is the
|
| 678 |
+
// cursor keys refusing to move through what you just typed.
|
| 679 |
+
{...(creating ? {} : { onKeyDown: onMenuKeyDown })}
|
| 680 |
+
dataKind={creating ? "create-new-name" : "create-new"}
|
| 681 |
>
|
| 682 |
+
{creating ? (
|
| 683 |
+
<div className="cg-view-create cg-create-form">
|
| 684 |
+
<label htmlFor="cg-new-view">
|
| 685 |
+
{/* The prompt NAMES the kind picked one step ago — the panel replaced its
|
| 686 |
+
own contents, so without this the box gives no sign that "Calendar" was
|
| 687 |
+
ever chosen. */}
|
| 688 |
+
New {MODE_LABELS[creating].toLowerCase()} view
|
| 689 |
+
</label>
|
| 690 |
+
<input
|
| 691 |
+
id="cg-new-view"
|
| 692 |
+
className="cg-input"
|
| 693 |
+
autoFocus
|
| 694 |
+
// The overlay's own initial-focus effect fires on MOUNT, and this panel does
|
| 695 |
+
// not re-mount between the two steps — so the input carries `autoFocus` (React
|
| 696 |
+
// focuses it when it mounts) and the marker the other in-panel forms use.
|
| 697 |
+
data-overlay-autofocus
|
| 698 |
+
value={name}
|
| 699 |
+
placeholder="e.g. Florida at risk"
|
| 700 |
+
onChange={(event) => setName(event.target.value)}
|
| 701 |
+
onKeyDown={(event) => {
|
| 702 |
+
if (event.key === "Enter") create();
|
| 703 |
+
// Escape is handled by the overlay layer too; both mean "abandon", and
|
| 704 |
+
// calling the same closer twice is idempotent.
|
| 705 |
+
if (event.key === "Escape") closeCreate();
|
| 706 |
+
}}
|
| 707 |
+
/>
|
| 708 |
+
{/* I17 (C4) — the owner's prompt ORDER: type (picked in step 1, named above)
|
| 709 |
+
→ who can edit → the user picker, and the picker only when it applies. */}
|
| 710 |
+
<div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
|
| 711 |
+
<span className="cg-perm-title">Who can edit</span>
|
| 712 |
+
{VIEW_EDIT_MODES.map((m) => (
|
| 713 |
+
<label key={m} className="cg-radio-row cg-perm-row">
|
| 714 |
+
<input
|
| 715 |
+
type="radio"
|
| 716 |
+
name="cg-view-perm"
|
| 717 |
+
checked={permEdit === m}
|
| 718 |
+
onChange={() => setPermEdit(m)}
|
| 719 |
+
/>
|
| 720 |
+
<span className="cg-perm-label">
|
| 721 |
+
{VIEW_EDIT_LABELS[m]}
|
| 722 |
+
<span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
|
| 723 |
+
</span>
|
| 724 |
+
</label>
|
| 725 |
+
))}
|
| 726 |
+
{permEdit === "users" && (
|
| 727 |
+
<div className="cg-perm-users">
|
| 728 |
+
{userOptions.length === 0 ? (
|
| 729 |
+
// Never a silent empty box: an empty grant collapses to Personal
|
| 730 |
+
// host-side, so say that rather than letting the user think they
|
| 731 |
+
// shared it.
|
| 732 |
+
<span className="cg-perm-empty">
|
| 733 |
+
No other accounts to pick — this will save as Personal.
|
| 734 |
+
</span>
|
| 735 |
+
) : (
|
| 736 |
+
userOptions.map((u) => (
|
| 737 |
+
<label key={u} className="cg-perm-user">
|
| 738 |
+
<input
|
| 739 |
+
type="checkbox"
|
| 740 |
+
checked={permUsers.includes(u)}
|
| 741 |
+
onChange={(e) =>
|
| 742 |
+
setPermUsers((cur) =>
|
| 743 |
+
e.target.checked
|
| 744 |
+
? [...cur, u].slice(0, MAX_VIEW_USERS)
|
| 745 |
+
: cur.filter((x) => x !== u)
|
| 746 |
+
)
|
| 747 |
+
}
|
| 748 |
+
/>
|
| 749 |
+
<span>{u}</span>
|
| 750 |
+
</label>
|
| 751 |
+
))
|
| 752 |
+
)}
|
| 753 |
+
{userOptions.length > 0 && permUsers.length === 0 && (
|
| 754 |
+
<span className="cg-perm-empty">
|
| 755 |
+
Pick at least one person, or this saves as Personal.
|
| 756 |
+
</span>
|
| 757 |
+
)}
|
| 758 |
+
</div>
|
| 759 |
+
)}
|
| 760 |
+
</div>
|
| 761 |
+
<div className="cg-form-actions">
|
| 762 |
+
<button
|
| 763 |
+
type="button"
|
| 764 |
+
className="cg-btn cg-btn--primary"
|
| 765 |
+
onClick={create}
|
| 766 |
+
disabled={!name.trim()}
|
| 767 |
+
>
|
| 768 |
+
Create
|
| 769 |
+
</button>
|
| 770 |
+
<button type="button" className="cg-btn" onClick={closeCreate}>
|
| 771 |
+
Cancel
|
| 772 |
+
</button>
|
| 773 |
+
</div>
|
| 774 |
+
</div>
|
| 775 |
+
) : (
|
| 776 |
<>
|
| 777 |
+
{CREATABLE_MODES.map((mode) => (
|
| 778 |
+
<button
|
| 779 |
+
key={mode}
|
| 780 |
+
type="button"
|
| 781 |
+
role="menuitem"
|
| 782 |
+
className="cg-create-row"
|
| 783 |
+
onClick={() => startView(mode)}
|
| 784 |
+
>
|
| 785 |
+
{/* size 16 — item 13's "bigger icons", and the SAME 16 the view rows use,
|
| 786 |
+
so a Calendar is one mark at one size on this whole surface. */}
|
| 787 |
+
<ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
|
| 788 |
+
<span>{MODE_LABELS[mode]}</span>
|
| 789 |
+
</button>
|
| 790 |
+
))}
|
| 791 |
+
{foldersOn && onFolderCreate && (
|
| 792 |
+
<>
|
| 793 |
+
<div className="cg-menu-sep" role="separator" aria-hidden />
|
| 794 |
+
<button
|
| 795 |
+
type="button"
|
| 796 |
+
role="menuitem"
|
| 797 |
+
className="cg-create-row"
|
| 798 |
+
onClick={() => startFolder()}
|
| 799 |
+
>
|
| 800 |
+
<FolderMark size={16} />
|
| 801 |
+
<span>Folder</span>
|
| 802 |
+
</button>
|
| 803 |
+
</>
|
| 804 |
+
)}
|
| 805 |
</>
|
| 806 |
)}
|
| 807 |
</AnchoredOverlay>
|
|
|
|
| 819 |
key={gid ?? "__root__"}
|
| 820 |
className={
|
| 821 |
(isRoot ? "cg-fold-root" : "cg-fold") +
|
| 822 |
+
(dropTarget === (gid ?? "__root__") ? " is-drop" : "") +
|
| 823 |
+
// Item 19 — the insertion rule reads "the folder you are dragging lands HERE":
|
| 824 |
+
// above this folder, or above the ungrouped section, which is after them all.
|
| 825 |
+
(foldOver === (gid ?? "__root__") && foldDrag && foldDrag !== gid
|
| 826 |
+
? " is-drop-above"
|
| 827 |
+
: "") +
|
| 828 |
+
(foldDrag === gid ? " is-folddrag" : "")
|
| 829 |
}
|
| 830 |
{...dropHandlers(gid)}
|
| 831 |
>
|
| 832 |
{group.folder && (
|
| 833 |
+
<div
|
| 834 |
+
className="cg-fold-head"
|
| 835 |
+
// ── Item 19 — the folder itself is the drag handle ─────────────────────
|
| 836 |
+
// Draggable only while it is renameable-idle: a `draggable` ancestor eats
|
| 837 |
+
// the text selection inside an input, so dragging would win over editing
|
| 838 |
+
// the name you just opened.
|
| 839 |
+
draggable={foldsReorderable && renamingFolder !== group.folder.id}
|
| 840 |
+
onDragStart={(e) => {
|
| 841 |
+
e.dataTransfer.setData(FOLD_DRAG_TYPE, group.folder!.id);
|
| 842 |
+
e.dataTransfer.effectAllowed = "move";
|
| 843 |
+
setFoldDrag(group.folder!.id);
|
| 844 |
+
}}
|
| 845 |
+
onDragEnd={() => {
|
| 846 |
+
setFoldDrag(null);
|
| 847 |
+
setFoldOver(null);
|
| 848 |
+
}}
|
| 849 |
+
>
|
| 850 |
+
{/* ⛔ NO DRAG GRIP, and that is a decision rather than an omission. A grip in
|
| 851 |
+
flow shifts the folder's name to the right, and this rail's indent is
|
| 852 |
+
MEASURED to the pixel (index.css: "root view name 41.00px / folder header
|
| 853 |
+
48.36px" — the whole point of the wave-10 item-2 fix); the gutter it would
|
| 854 |
+
otherwise hide in is already occupied by the disclosure chevron, whose
|
| 855 |
+
open/shut state is the only one this row has. The view rows above have been
|
| 856 |
+
draggable with no handle since wave 8, so a folder that drags the same way
|
| 857 |
+
is the vocabulary this list already teaches, not a hidden feature. */}
|
| 858 |
<button
|
| 859 |
type="button"
|
| 860 |
className="cg-fold-toggle"
|
|
|
|
| 897 |
) : (
|
| 898 |
<span className="cg-fold-name">{group.folder.name}</span>
|
| 899 |
)}
|
| 900 |
+
{/* ⛔ WAVE 20 item 21 — THE VIEW COUNT IS DELETED, and the item is not
|
| 901 |
+
really about the number. `.cg-view-more` (the "…" beside this button)
|
| 902 |
+
ships `opacity: 0` and is revealed by `.cg-view-row:hover`; a folder
|
| 903 |
+
head is not a view row, so NO rule ever revealed it — the folder's
|
| 904 |
+
actions button was painted at zero opacity in every state but keyboard
|
| 905 |
+
focus. The badge is what the owner saw sitting where the control should
|
| 906 |
+
have been. Deleting it and making the "…" unconditionally visible (one
|
| 907 |
+
rule in this wave's S4 region) are the two halves of one fix.
|
| 908 |
+
⚠ `.cg-fold-count` in index.css now has no consumer in this tree. Left
|
| 909 |
+
in place rather than swept blind — same posture as `.cg-fold-new` above
|
| 910 |
+
— and booked in the S4 mailbox so the sweep is a decision, not a
|
| 911 |
+
discovery. The NAV rail's own count stays: that rail reveals its "…" on
|
| 912 |
+
hover already, so it never had this defect and item 21 never named it. */}
|
| 913 |
</button>
|
| 914 |
+
{/* ⛔ THE SYSTEM FOLDER HAS NO ACTIONS (item 18 / C-SHARE). "Shared with
|
| 915 |
+
me" is not the reader's folder: it cannot be renamed into something
|
| 916 |
+
else, duplicated into a second copy of other people's work, or
|
| 917 |
+
deleted — the contract calls it undeletable, and a menu whose every
|
| 918 |
+
row the server would refuse is three fake affordances rather than
|
| 919 |
+
one. Views MOVE OUT of it normally (that is per-receiver placement),
|
| 920 |
+
which is the only thing anyone needs to do to it. */}
|
| 921 |
+
{group.folder.id === SHARED_FOLDER_ID ? null : (
|
| 922 |
<button
|
| 923 |
type="button"
|
| 924 |
className="cg-view-more"
|
|
|
|
| 936 |
>
|
| 937 |
···
|
| 938 |
</button>
|
| 939 |
+
)}
|
| 940 |
</div>
|
| 941 |
)}
|
| 942 |
{/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
|
|
|
|
| 1026 |
so explicitly), so most rows have exactly one reason. When a row has both,
|
| 1027 |
the row-set meaning leads and the title carries the other — never two
|
| 1028 |
padlocks side by side, which would read as a bug. */}
|
| 1029 |
+
{/* ⭐ WAVE 20 item 23 (C-SHARE) — THE SECOND MARK, beside the padlock
|
| 1030 |
+
and never instead of it. They answer different questions: the lock
|
| 1031 |
+
says what this view may become, this says how it got to you. A
|
| 1032 |
+
view someone shared with you is one you can be looking at without
|
| 1033 |
+
having made it — the single most useful thing the rail can tell you
|
| 1034 |
+
before the click.
|
| 1035 |
+
⚠ `shared` is not on `SavedView` yet (S3, amendment A-S4-4). Read
|
| 1036 |
+
through ONE narrowed cast, here, rather than loosening the type
|
| 1037 |
+
everywhere: an absent flag reads as false, which is exactly right
|
| 1038 |
+
until the server projects it. */}
|
| 1039 |
+
{(view as { shared?: boolean }).shared ? (
|
| 1040 |
+
<span
|
| 1041 |
+
className="cg-view-shared"
|
| 1042 |
+
role="img"
|
| 1043 |
+
aria-label={`${view.name}, shared with you`}
|
| 1044 |
+
title="Shared with you — someone gave you access to this view."
|
| 1045 |
+
>
|
| 1046 |
+
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
|
| 1047 |
+
<circle cx="5.6" cy="5.6" r="2.2" fill="none" stroke="currentColor"
|
| 1048 |
+
strokeWidth="1.3" />
|
| 1049 |
+
<path d="M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2"
|
| 1050 |
+
fill="none" stroke="currentColor" strokeWidth="1.3"
|
| 1051 |
+
strokeLinecap="round" />
|
| 1052 |
+
<path d="M10.6 4.1a2.2 2.2 0 0 1 0 4.2M11.4 9.7c1.6.3 2.7 1.4 2.7 2.9"
|
| 1053 |
+
fill="none" stroke="currentColor" strokeWidth="1.3"
|
| 1054 |
+
strokeLinecap="round" />
|
| 1055 |
+
</svg>
|
| 1056 |
+
</span>
|
| 1057 |
+
) : null}
|
| 1058 |
{(view.kind === "locked" || isModeFrozen(view)) && (
|
| 1059 |
<span
|
| 1060 |
className="cg-view-lock"
|
|
|
|
| 1139 |
The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
|
| 1140 |
</div>
|
| 1141 |
|
| 1142 |
+
{/* I11c — the folder "…" menu. Rename · Add to cohort · Duplicate · Delete.
|
| 1143 |
+
WAVE 20 item 22 — every row now carries the SAME icon vocabulary the view row's
|
| 1144 |
+
menu uses (`MenuLabel` over `MENU_ICONS`), and Delete is red AT REST rather than
|
| 1145 |
+
only once armed. Two menus that do the same kind of thing to two kinds of object
|
| 1146 |
+
were drawn in two registers: one with icons and a red delete, one with neither.
|
| 1147 |
+
⚠ NO NEW CSS FOR THE COLOUR: this overlay already wears `cg-view-menu`, so adding
|
| 1148 |
+
`is-danger` to the button reuses `.cg-view-menu button.is-danger` — the exact rule
|
| 1149 |
+
that paints the view row's Delete, which is what "matching" has to mean. The
|
| 1150 |
+
`cg-menu-item--danger` class stays for its armed background. */}
|
| 1151 |
{folderMenu && folderMenuF && (
|
| 1152 |
<AnchoredOverlay
|
| 1153 |
anchor={folderMenu.anchor}
|
|
|
|
| 1170 |
setFolderMenu(null);
|
| 1171 |
}}
|
| 1172 |
>
|
| 1173 |
+
<MenuLabel icon="rename" text="Rename" />
|
| 1174 |
</button>
|
| 1175 |
{folderAddPreview && onFolderAddToList && (
|
| 1176 |
<button
|
|
|
|
| 1184 |
setFolderMenu(null);
|
| 1185 |
}}
|
| 1186 |
>
|
| 1187 |
+
<MenuLabel icon="cohortAdd" text="Add to cohort…" />
|
| 1188 |
</button>
|
| 1189 |
)}
|
| 1190 |
{onFolderDuplicate && (
|
|
|
|
| 1197 |
setFolderMenu(null);
|
| 1198 |
}}
|
| 1199 |
>
|
| 1200 |
+
<MenuLabel icon="duplicate" text="Duplicate folder and its views" />
|
| 1201 |
</button>
|
| 1202 |
)}
|
| 1203 |
+
{/* WAVE 20 item 18 (R10) — a folder shares, and its views ride along
|
| 1204 |
+
(the server's rule, stated in C-SHARE; this row only opens the editor). */}
|
| 1205 |
+
<button
|
| 1206 |
+
type="button"
|
| 1207 |
+
role="menuitem"
|
| 1208 |
+
className="cg-menu-item"
|
| 1209 |
+
onClick={() => {
|
| 1210 |
+
openShare("folder", folderMenuF.id, folderMenuF.name);
|
| 1211 |
+
setFolderMenu(null);
|
| 1212 |
+
}}
|
| 1213 |
+
>
|
| 1214 |
+
<MenuLabel icon="permissions" text="Share folder…" />
|
| 1215 |
+
</button>
|
| 1216 |
{onFolderDelete && (
|
| 1217 |
<button
|
| 1218 |
type="button"
|
| 1219 |
role="menuitem"
|
| 1220 |
+
className={
|
| 1221 |
+
"cg-menu-item cg-menu-item--danger is-danger" +
|
| 1222 |
+
(confirmDelete === folderMenuF.id ? " is-armed" : "")
|
| 1223 |
+
}
|
| 1224 |
onClick={() => {
|
| 1225 |
if (confirmDelete !== folderMenuF.id) {
|
| 1226 |
setConfirmDelete(folderMenuF.id);
|
|
|
|
| 1231 |
setFolderMenu(null);
|
| 1232 |
}}
|
| 1233 |
>
|
| 1234 |
+
{/* The ARMED label is a whole sentence and the menu row is `nowrap` with a
|
| 1235 |
+
260px ceiling, so it used to run out of the panel. It wraps while armed
|
| 1236 |
+
(one rule in the S4 region) rather than being ellipsised: a confirmation
|
| 1237 |
+
the reader cannot finish reading is not a confirmation. */}
|
| 1238 |
+
<MenuLabel
|
| 1239 |
+
icon="trash"
|
| 1240 |
+
text={
|
| 1241 |
+
confirmDelete === folderMenuF.id
|
| 1242 |
+
? "Delete folder? Its views move to the top level."
|
| 1243 |
+
: "Delete folder"
|
| 1244 |
+
}
|
| 1245 |
+
/>
|
| 1246 |
</button>
|
| 1247 |
)}
|
| 1248 |
</AnchoredOverlay>
|
|
|
|
| 1440 |
>
|
| 1441 |
<MenuLabel icon="duplicate" text="Duplicate" />
|
| 1442 |
</button>
|
| 1443 |
+
{/* WAVE 20 items 23/26 (R10) — "who else can reach this view", the same
|
| 1444 |
+
editor a folder and a database open. Deliberately NOT gated on
|
| 1445 |
+
`canEditMenuView`: the server decides who may administer (owner or
|
| 1446 |
+
admin) and says so in the dialog, and hiding the row from everyone else
|
| 1447 |
+
would hide the ANSWER too — "who has this?" is a fair question for
|
| 1448 |
+
anyone the view was shared with. */}
|
| 1449 |
+
<button
|
| 1450 |
+
type="button"
|
| 1451 |
+
role="menuitem"
|
| 1452 |
+
onClick={() => {
|
| 1453 |
+
openShare("view", menuView.id, menuView.name);
|
| 1454 |
+
setMenu(null);
|
| 1455 |
+
}}
|
| 1456 |
+
>
|
| 1457 |
+
<MenuLabel icon="permissions" text="Share view…" />
|
| 1458 |
+
</button>
|
| 1459 |
+
{/* WAVE 20 item 25 (C-ALERT) — the door that MAKES an alert, on the view it
|
| 1460 |
+
watches. It belongs here and nowhere else: an alert IS "tell me when a
|
| 1461 |
+
record enters THIS view", so the only place the question has an obvious
|
| 1462 |
+
subject is the view's own menu.
|
| 1463 |
+
⚠ The frame answers, because this rail does not know its own topic (it
|
| 1464 |
+
holds views; the scope key belongs to the route) — and because the server
|
| 1465 |
+
refuses a view with no active filter, which is a message the frame is
|
| 1466 |
+
already in the business of showing. */}
|
| 1467 |
+
<button
|
| 1468 |
+
type="button"
|
| 1469 |
+
role="menuitem"
|
| 1470 |
+
onClick={() => {
|
| 1471 |
+
window.dispatchEvent(
|
| 1472 |
+
new CustomEvent("aios:alert-create", {
|
| 1473 |
+
detail: { viewId: menuView.id, label: menuView.name },
|
| 1474 |
+
})
|
| 1475 |
+
);
|
| 1476 |
+
setMenu(null);
|
| 1477 |
+
}}
|
| 1478 |
+
>
|
| 1479 |
+
<MenuLabel icon="cohortAdd" text="Alert me about new records…" />
|
| 1480 |
+
</button>
|
| 1481 |
{onAddToList && (
|
| 1482 |
<button
|
| 1483 |
type="button"
|
web/src/customer-grid/apiBridge.ts
CHANGED
|
@@ -219,6 +219,78 @@ export function patchCustomer(pid: number, updates: Partial<Row>): Promise<boole
|
|
| 219 |
return patchTopicRow(CUSTOMER_TOPIC, pid, updates);
|
| 220 |
}
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
/**
|
| 223 |
* Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`.
|
| 224 |
*
|
|
|
|
| 219 |
return patchTopicRow(CUSTOMER_TOPIC, pid, updates);
|
| 220 |
}
|
| 221 |
|
| 222 |
+
/**
|
| 223 |
+
* ⭐ Wave-20 owner item 4 / contract C-ADDROW — **APPEND A ROW TO A USER DATABASE**, and
|
| 224 |
+
* (C-UNDO) restore a deleted one under its old id.
|
| 225 |
+
*
|
| 226 |
+
* USER TABLES ONLY, and the refusal is structural rather than checked here: no other scope has
|
| 227 |
+
* a `/tables/{key}/rows` endpoint at all. A connector's rows are read-synced from its source —
|
| 228 |
+
* R8 is explicit that a "+" which must refuse is a fake affordance, so the caller never renders
|
| 229 |
+
* one there.
|
| 230 |
+
*
|
| 231 |
+
* ⚠ THE ANSWER IS THE ID THAT WAS STORED, never the one that was asked for. An undo that
|
| 232 |
+
* requests `rid` may find that id re-used, and the server's own note says it answers with what
|
| 233 |
+
* it actually wrote; the caller re-anchors on the returned value rather than assuming.
|
| 234 |
+
*
|
| 235 |
+
* ⚠ The rows cache is CLEARED on success. `fetchTopicRows` holds a 5-minute window per topic,
|
| 236 |
+
* so a re-read straight after an append would serve the payload from before it — the new row
|
| 237 |
+
* would appear minutes later, which reads as "the button did nothing". Same reason the shell's
|
| 238 |
+
* retired Add-record bar cleared it (wave 20 item 4 moved that door here).
|
| 239 |
+
*/
|
| 240 |
+
export async function addTableRow(
|
| 241 |
+
tableKey: string,
|
| 242 |
+
values: Record<string, unknown> = {},
|
| 243 |
+
rid?: string | number
|
| 244 |
+
): Promise<{ rid: string | number; pid: number } | null> {
|
| 245 |
+
try {
|
| 246 |
+
const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows`, {
|
| 247 |
+
method: "POST",
|
| 248 |
+
credentials: CREDENTIALS,
|
| 249 |
+
headers: JSON_HEADERS,
|
| 250 |
+
body: JSON.stringify(rid === undefined ? { values } : { rid, values }),
|
| 251 |
+
});
|
| 252 |
+
if (handledUnauthorized(res.status)) return null;
|
| 253 |
+
const body = (await readJson(res)) as { rid?: string | number; pid?: number;
|
| 254 |
+
detail?: { message?: string } } | null;
|
| 255 |
+
if (!res.ok) {
|
| 256 |
+
// The server states WHY (a row cap, a store refusal). Surfacing its sentence beats a
|
| 257 |
+
// generic failure toast, and staying silent would be the worst of the three.
|
| 258 |
+
const why = typeof body?.detail?.message === "string"
|
| 259 |
+
? body.detail.message
|
| 260 |
+
: `The server answered ${res.status}.`;
|
| 261 |
+
signal(TOAST_EVENT, why);
|
| 262 |
+
return null;
|
| 263 |
+
}
|
| 264 |
+
if (body?.rid === undefined || typeof body?.pid !== "number") {
|
| 265 |
+
signal(DATA_ERROR_EVENT, "The server did not say which row it created.");
|
| 266 |
+
return null;
|
| 267 |
+
}
|
| 268 |
+
rowsCache.delete(`tables/${tableKey}/rows`);
|
| 269 |
+
return { rid: body.rid, pid: body.pid };
|
| 270 |
+
} catch {
|
| 271 |
+
signal(DATA_ERROR_EVENT, "Cannot reach the server.");
|
| 272 |
+
return null;
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
/** C-UNDO's other half: drop a row this session just added. Same cache rule as the append. */
|
| 277 |
+
export async function deleteTableRow(
|
| 278 |
+
tableKey: string,
|
| 279 |
+
rid: string | number
|
| 280 |
+
): Promise<boolean> {
|
| 281 |
+
try {
|
| 282 |
+
const res = await fetch(
|
| 283 |
+
`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows/${encodeURIComponent(String(rid))}`,
|
| 284 |
+
{ method: "DELETE", credentials: CREDENTIALS }
|
| 285 |
+
);
|
| 286 |
+
if (handledUnauthorized(res.status)) return false;
|
| 287 |
+
if (res.ok) rowsCache.delete(`tables/${tableKey}/rows`);
|
| 288 |
+
return res.ok;
|
| 289 |
+
} catch {
|
| 290 |
+
return false;
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
/**
|
| 295 |
* Item 7 (contract C-TS) — `POST /api/v1/grid/timeseries`.
|
| 296 |
*
|
web/src/customer-grid/folders.ts
CHANGED
|
@@ -30,6 +30,16 @@ export interface FolderStamps {
|
|
| 30 |
deleted?: Record<string, number>;
|
| 31 |
/** itemId -> {at, folderId} for a drag this browser just performed. */
|
| 32 |
moved?: Record<string, { at: number; folderId: string | null }>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export const FOLDER_STAMP_MAX = 64;
|
|
@@ -59,6 +69,9 @@ export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number)
|
|
| 59 |
if (renamed) out.renamed = renamed;
|
| 60 |
if (deleted) out.deleted = deleted;
|
| 61 |
if (moved) out.moved = moved;
|
|
|
|
|
|
|
|
|
|
| 62 |
return out;
|
| 63 |
}
|
| 64 |
|
|
@@ -102,7 +115,28 @@ export function reconcileFolders(
|
|
| 102 |
out.push(f);
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
/**
|
|
@@ -158,6 +192,44 @@ export function groupByFolder<T>(
|
|
| 158 |
return out;
|
| 159 |
}
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
/** A fresh folder id. Client-generated, like every other id in this component. */
|
| 162 |
export function newFolderId(): string {
|
| 163 |
const rand =
|
|
|
|
| 30 |
deleted?: Record<string, number>;
|
| 31 |
/** itemId -> {at, folderId} for a drag this browser just performed. */
|
| 32 |
moved?: Record<string, { at: number; folderId: string | null }>;
|
| 33 |
+
/**
|
| 34 |
+
* WAVE 20 item 19 (C-FOLDER-REORDER) — the FULL folder order this browser just set.
|
| 35 |
+
*
|
| 36 |
+
* ONE stamp, not one per folder, because a reorder is one decision about a list: the
|
| 37 |
+
* order the user dropped into is the order they want, and reconstructing it from N
|
| 38 |
+
* per-folder stamps would let two of them age out at different moments and leave a
|
| 39 |
+
* sequence nobody ever chose. The host answers with `order` NUMBERS on each folder
|
| 40 |
+
* (that is the durable form); this is what to render until it does.
|
| 41 |
+
*/
|
| 42 |
+
ordered?: { at: number; order: string[] };
|
| 43 |
}
|
| 44 |
|
| 45 |
export const FOLDER_STAMP_MAX = 64;
|
|
|
|
| 69 |
if (renamed) out.renamed = renamed;
|
| 70 |
if (deleted) out.deleted = deleted;
|
| 71 |
if (moved) out.moved = moved;
|
| 72 |
+
// Item 19: a single stamp, so it is kept or dropped whole — pruning it by halves is
|
| 73 |
+
// exactly the partial sequence the field's own note refuses.
|
| 74 |
+
if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
|
| 75 |
return out;
|
| 76 |
}
|
| 77 |
|
|
|
|
| 115 |
out.push(f);
|
| 116 |
}
|
| 117 |
|
| 118 |
+
out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
|
| 119 |
+
|
| 120 |
+
// ── WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
|
| 121 |
+
//
|
| 122 |
+
// Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
|
| 123 |
+
// · the host's `order` numbers are the durable truth and stay the base sequence, so a
|
| 124 |
+
// folder the stamp never names keeps exactly the place the server gave it;
|
| 125 |
+
// · `Array.prototype.sort` is stable (ES2019), so every unnamed folder — one created in
|
| 126 |
+
// another tab between the drag and the echo, say — holds its relative position at the
|
| 127 |
+
// end instead of being flung to the front by a missing rank.
|
| 128 |
+
// A stamped id that has since been DELETED needs no handling: the tombstone pass above
|
| 129 |
+
// already dropped it, and `rank` is only ever consulted for folders that survived.
|
| 130 |
+
const ordered = stamps?.ordered;
|
| 131 |
+
if (isRecent(ordered?.at, now) && ordered) {
|
| 132 |
+
const rank = new Map(ordered.order.map((id, i) => [id, i]));
|
| 133 |
+
out.sort(
|
| 134 |
+
(a, b) =>
|
| 135 |
+
(rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
|
| 136 |
+
(rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
return out;
|
| 140 |
}
|
| 141 |
|
| 142 |
/**
|
|
|
|
| 192 |
return out;
|
| 193 |
}
|
| 194 |
|
| 195 |
+
/**
|
| 196 |
+
* WAVE 20 item 19 (C-FOLDER-REORDER) — where a dragged folder lands: the full order with
|
| 197 |
+
* `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the
|
| 198 |
+
* drop on the ungrouped section below every folder).
|
| 199 |
+
*
|
| 200 |
+
* Here rather than inside the rail because it is the only part of the drag a test can hold:
|
| 201 |
+
* the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides
|
| 202 |
+
* what the user sees. `null` means "emit nothing" — an unknown id, or a drop that changes
|
| 203 |
+
* nothing. Returning the unchanged array instead would be worse than useless: the caller
|
| 204 |
+
* cannot tell it apart from a real reorder, so every no-op drag would write the store, bump
|
| 205 |
+
* every reader's payload, and reconcile to the identical list.
|
| 206 |
+
*
|
| 207 |
+
* ⚠ The dragged id is REMOVED BEFORE the target index is read. Taking the index first and
|
| 208 |
+
* splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one
|
| 209 |
+
* place short of where it was dropped, and only in that direction — the shape of bug that
|
| 210 |
+
* survives a demo and gets reported as "it sometimes doesn't move".
|
| 211 |
+
*/
|
| 212 |
+
export function reorderFolderIds(
|
| 213 |
+
ids: string[],
|
| 214 |
+
draggedId: string,
|
| 215 |
+
beforeId: string | null
|
| 216 |
+
): string[] | null {
|
| 217 |
+
if (!ids.includes(draggedId)) return null;
|
| 218 |
+
// ⛔ DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
|
| 219 |
+
// target, and the "not found" branch sends the folder to the END — so releasing a drag over
|
| 220 |
+
// the folder you picked up would quietly move it to the bottom of the rail. Found by this
|
| 221 |
+
// function's own gate the minute the arithmetic left the component; the drop handler's
|
| 222 |
+
// indicator suppresses the same case visually, which is exactly why it would never have
|
| 223 |
+
// been noticed there.
|
| 224 |
+
if (beforeId === draggedId) return null;
|
| 225 |
+
const rest = ids.filter((id) => id !== draggedId);
|
| 226 |
+
const found = beforeId ? rest.indexOf(beforeId) : -1;
|
| 227 |
+
const at = found < 0 ? rest.length : found;
|
| 228 |
+
const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
|
| 229 |
+
if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
|
| 230 |
+
return next;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
/** A fresh folder id. Client-generated, like every other id in this component. */
|
| 234 |
export function newFolderId(): string {
|
| 235 |
const rand =
|
web/src/customer-grid/liveWorkspace.ts
CHANGED
|
@@ -81,6 +81,52 @@ export function stampTombstone(stamps: Tombstones | undefined, id: string, now:
|
|
| 81 |
return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
|
| 82 |
}
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
/**
|
| 85 |
* Views that have APPEARED on the host since this browser last looked.
|
| 86 |
*
|
|
|
|
| 81 |
return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
|
| 82 |
}
|
| 83 |
|
| 84 |
+
/**
|
| 85 |
+
* ⭐ D-19 (wave 20) — **THE LOCALSTORAGE GHOST.**
|
| 86 |
+
*
|
| 87 |
+
* At init the grid seeded EVERY view from localStorage and then merged the host's list over the
|
| 88 |
+
* top, so a view the host no longer names simply survived — for ever, in that browser. Three
|
| 89 |
+
* ordinary paths produce one: the view was deleted from another tab or another machine, its
|
| 90 |
+
* share was revoked, or the store moved under it. The row keeps working until you click it, and
|
| 91 |
+
* then it is a saved view nobody else can see and no write can reach; the owner reported it as
|
| 92 |
+
* "live and staging disagree". Item 13's pg cutover makes the host list authoritative for real,
|
| 93 |
+
* which turns a rare confusion into a visible one.
|
| 94 |
+
*
|
| 95 |
+
* So: **the host's list decides which views exist.** A local copy the host does not name is
|
| 96 |
+
* dropped — with two guards, and neither is optional:
|
| 97 |
+
*
|
| 98 |
+
* 1. `hostAuthoritative === false` keeps everything. Standalone with no `/workspace` (and the
|
| 99 |
+
* legacy embed) has no host list at all, and "not named" there means "not asked", not
|
| 100 |
+
* "deleted". Dropping on a payload that never carried views would empty the rail.
|
| 101 |
+
* ⛔ **An EMPTY host list counts as not-authoritative for the same reason, and this is the
|
| 102 |
+
* one branch that could destroy data.** A `/workspace` answering `200 {views: []}` is
|
| 103 |
+
* indistinguishable from a store that has not answered yet — a scope whose bucket is
|
| 104 |
+
* briefly empty during item 13's `hf → pg` migration, a fresh backend, a bucket that was
|
| 105 |
+
* never seeded. Dropping there wipes every saved view in that browser, and the persist
|
| 106 |
+
* effect rewrites localStorage immediately after, so there is no second chance. The ghost
|
| 107 |
+
* this exists for is a view missing from a NON-EMPTY list; nothing is lost by refusing to
|
| 108 |
+
* act on no list at all.
|
| 109 |
+
* 2. A view THIS BROWSER wrote inside the echo window survives. A create is optimistic: the
|
| 110 |
+
* row exists locally the instant it is made, and the host cannot name it until its
|
| 111 |
+
* `view_upsert` has been sent AND the next `/workspace` read has come back. Without this
|
| 112 |
+
* guard, creating a view and reloading fast enough would delete it — the exact inverse of
|
| 113 |
+
* the bug, and a worse one.
|
| 114 |
+
*
|
| 115 |
+
* Same window, same reasoning and the same stamp shape as the tombstones above: past
|
| 116 |
+
* ECHO_RECENT_MS, divergence is not an echo.
|
| 117 |
+
*/
|
| 118 |
+
export function seedLocalViews(
|
| 119 |
+
localViews: readonly SavedView[],
|
| 120 |
+
hostViews: readonly SavedView[],
|
| 121 |
+
writes: Tombstones | undefined,
|
| 122 |
+
now: number,
|
| 123 |
+
hostAuthoritative: boolean
|
| 124 |
+
): SavedView[] {
|
| 125 |
+
if (!hostAuthoritative || hostViews.length === 0) return [...localViews];
|
| 126 |
+
const named = new Set(hostViews.map((v) => v.id));
|
| 127 |
+
return localViews.filter((v) => named.has(v.id) || isRecent(writes?.[v.id], now));
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
/**
|
| 131 |
* Views that have APPEARED on the host since this browser last looked.
|
| 132 |
*
|
web/src/customer-grid/overlayPlacement.ts
CHANGED
|
@@ -480,9 +480,22 @@ export function computeOverlayPosition(args: {
|
|
| 480 |
side = placeBelow ? "below" : "above";
|
| 481 |
maxHeight = Math.max(1, placeBelow ? below : above);
|
| 482 |
const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
top = placeBelow
|
| 485 |
-
? Math.min(target.bottom + gap, maxBottom - maxHeight)
|
| 486 |
: Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
|
| 487 |
}
|
| 488 |
|
|
|
|
| 480 |
side = placeBelow ? "below" : "above";
|
| 481 |
maxHeight = Math.max(1, placeBelow ? below : above);
|
| 482 |
const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
|
| 483 |
+
// The horizontal clamp needs no `Math.max(minLeft, …)` on its outer bound, unlike the
|
| 484 |
+
// `right-start` branch above: `width` is ALREADY clamped to `maxWidth = maxRight - minLeft`,
|
| 485 |
+
// so `maxRight - width >= minLeft` always holds and the min cannot undo the max. Proven the
|
| 486 |
+
// only way worth proving it — a mutation that removed the "fix" left every leg green, which
|
| 487 |
+
// is what a guard against an impossible state looks like ([[gate-negative-control]]).
|
| 488 |
left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
|
| 489 |
+
// ⚠ D-20 (wave 20) — `Math.max(minTop, …)` IS load-bearing, and it was missing.
|
| 490 |
+
//
|
| 491 |
+
// The below-branch had no lower bound, so an anchor ABOVE the viewport put the panel above
|
| 492 |
+
// it too — painted, and unreachable. Not hypothetical: it is the null-anchor case D-20 is
|
| 493 |
+
// about (a null anchor degrades to a ZERO rect at the DOCUMENT origin, which is above the
|
| 494 |
+
// viewport whenever `visualViewport` carries an offset — a pinch-zoomed phone), and it is
|
| 495 |
+
// equally a trigger that scrolls out of view with its menu still open. `verify_overlay`'s
|
| 496 |
+
// `below-branch-loses-its-top-clamp` control restages exactly this.
|
| 497 |
top = placeBelow
|
| 498 |
+
? Math.max(minTop, Math.min(target.bottom + gap, maxBottom - maxHeight))
|
| 499 |
: Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
|
| 500 |
}
|
| 501 |
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -963,10 +963,21 @@ export function directionLabel(t: FieldType, dir: "asc" | "desc"): string {
|
|
| 963 |
}
|
| 964 |
|
| 965 |
/**
|
| 966 |
-
* Groupable = a CLOSED vocabulary (status/select/multiselect/checkbox) or
|
| 967 |
-
*
|
| 968 |
* ONE definition, consumed by the toolbar's Group panel and the column menu's "Group by this
|
| 969 |
* field" alike — two copies of this predicate would drift exactly like operator vocabularies.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
*/
|
| 971 |
export function isGroupableField(field: Field, lockedKey: string): boolean {
|
| 972 |
return (
|
|
@@ -974,7 +985,7 @@ export function isGroupableField(field: Field, lockedKey: string): boolean {
|
|
| 974 |
field.type === "select" ||
|
| 975 |
field.type === "multiselect" ||
|
| 976 |
field.type === "checkbox" ||
|
| 977 |
-
(field.type === "text" && field.
|
| 978 |
);
|
| 979 |
}
|
| 980 |
|
|
@@ -1134,6 +1145,19 @@ export interface Field {
|
|
| 1134 |
* which is one popover away and is the governed path (CG-8/CG-12's replacement rule).
|
| 1135 |
*/
|
| 1136 |
measure?: { key: string; window: WindowSpec };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1137 |
}
|
| 1138 |
|
| 1139 |
/** One data record. Always carries a stable `pid` — the semantic identity that
|
|
@@ -1516,6 +1540,144 @@ export function isRuleActive(rule: FilterRule): boolean {
|
|
| 1516 |
return rule.value !== "";
|
| 1517 |
}
|
| 1518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1519 |
/**
|
| 1520 |
* CG-8 — a measure the condition builder may offer: FILTERABLE BUT NEVER DISPLAYABLE.
|
| 1521 |
*
|
|
@@ -2382,6 +2544,30 @@ export type HostEvent =
|
|
| 2382 |
* global, so an old bundle keeps today's behavior byte-for-byte.
|
| 2383 |
*/
|
| 2384 |
| { id: string; type: "field_upsert"; field: Field; scope?: FieldScope }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2385 |
/**
|
| 2386 |
* Wave-5 item 1 — Duplicate field (contract AMENDMENT ~20:20: the CLIENT generates the
|
| 2387 |
* destination `key`, same prefix as `sourceKey` — the established key-generation pattern —
|
|
|
|
| 963 |
}
|
| 964 |
|
| 965 |
/**
|
| 966 |
+
* Groupable = a CLOSED vocabulary (status/select/multiselect/checkbox) or ANY text attribute
|
| 967 |
+
* other than the locked identity column (unique per row -> useless as a group key).
|
| 968 |
* ONE definition, consumed by the toolbar's Group panel and the column menu's "Group by this
|
| 969 |
* field" alike — two copies of this predicate would drift exactly like operator vocabularies.
|
| 970 |
+
*
|
| 971 |
+
* ⭐ Wave-20 owner item 11 — `field.source !== "overlay"` is GONE. A user-created text column
|
| 972 |
+
* was the one text field you could not group by, which is backwards: those are the columns
|
| 973 |
+
* holding the reader's OWN vocabulary (a segment, an owner, a call outcome), and they are
|
| 974 |
+
* exactly what a person wants to bucket a table by. The exclusion was never a rule about
|
| 975 |
+
* groupability — the only text field that is useless as a group key is the identity column,
|
| 976 |
+
* and that is tested for on its own.
|
| 977 |
+
*
|
| 978 |
+
* ⚠ Free text does NOT become a closed vocabulary by being grouped: `groupRows` keys on the
|
| 979 |
+
* TRIMMED cell, so "Miami" and "Miami " are one bucket, and blanks fall into the same
|
| 980 |
+
* "(empty)" group every other type uses.
|
| 981 |
*/
|
| 982 |
export function isGroupableField(field: Field, lockedKey: string): boolean {
|
| 983 |
return (
|
|
|
|
| 985 |
field.type === "select" ||
|
| 986 |
field.type === "multiselect" ||
|
| 987 |
field.type === "checkbox" ||
|
| 988 |
+
(field.type === "text" && field.key !== lockedKey)
|
| 989 |
);
|
| 990 |
}
|
| 991 |
|
|
|
|
| 1145 |
* which is one popover away and is the governed path (CG-8/CG-12's replacement rule).
|
| 1146 |
*/
|
| 1147 |
measure?: { key: string; window: WindowSpec };
|
| 1148 |
+
/**
|
| 1149 |
+
* ⭐ Wave-20 R2 / contract C-FIELD — **who may edit THIS COLUMN'S DEFINITION** on a `ut_*`
|
| 1150 |
+
* table, whose fields are the TABLE's schema rather than one user's overlay. `admins` (the
|
| 1151 |
+
* fail-closed default) means the database's creator or an admin; `everyone` opens this one
|
| 1152 |
+
* column without handing over the table.
|
| 1153 |
+
*
|
| 1154 |
+
* ⚠ IT GOVERNS THE SCHEMA, NEVER THE VALUES. `everyone` means anybody with access may rename
|
| 1155 |
+
* the column or change its options — not that anybody may type in its cells. Those are
|
| 1156 |
+
* different questions, and conflating them would turn a column's settings into a
|
| 1157 |
+
* data-permission system nobody wrote (`user_tables._clean_field` says the same thing from
|
| 1158 |
+
* the other side; `verify_grid_ux`'s C-FIELD parity holds the two shapes together).
|
| 1159 |
+
*/
|
| 1160 |
+
editRole?: "admins" | "everyone";
|
| 1161 |
}
|
| 1162 |
|
| 1163 |
/** One data record. Always carries a stable `pid` — the semantic identity that
|
|
|
|
| 1540 |
return rule.value !== "";
|
| 1541 |
}
|
| 1542 |
|
| 1543 |
+
/**
|
| 1544 |
+
* ⭐ Wave-20 owner item 15 (contract C-RENAME) — **THE RENAME MAPPING, BY ROW IDENTITY.**
|
| 1545 |
+
*
|
| 1546 |
+
* Given the option rows as they stand and what each row SAID when the editor opened, which
|
| 1547 |
+
* renames did the user make? A row that kept its id and changed its label was renamed; an id
|
| 1548 |
+
* nobody has seen is a new option; an id that is gone was deleted. That is why the answer is
|
| 1549 |
+
* exact rather than a guess: two lists cannot distinguish "renamed Blue to Navy" from "deleted
|
| 1550 |
+
* Blue, added Navy", and picking wrong empties every cell that said Blue.
|
| 1551 |
+
*
|
| 1552 |
+
* Two cases are deliberately NOT renames:
|
| 1553 |
+
* · a row emptied to "" — a label being retyped is not a rename to nothing;
|
| 1554 |
+
* · a rename ONTO an option that already exists — that is a MERGE, and merging two choices is
|
| 1555 |
+
* a different operation with a different answer for the cells that held either. Refused
|
| 1556 |
+
* here rather than sent as a rename the host would apply as one.
|
| 1557 |
+
*
|
| 1558 |
+
* Lives in types.ts, not beside the editor, for the usual reason: ColumnMenu imports React and
|
| 1559 |
+
* cannot be loaded by a node gate, and this is the half that has to be right.
|
| 1560 |
+
*/
|
| 1561 |
+
export function choiceRenames(
|
| 1562 |
+
rows: readonly { id: string; label: string }[],
|
| 1563 |
+
origin: ReadonlyMap<string, string>,
|
| 1564 |
+
savedChoices: readonly string[]
|
| 1565 |
+
): { from: string; to: string }[] {
|
| 1566 |
+
const existing = new Set(savedChoices.map((o) => o.toLowerCase()));
|
| 1567 |
+
const out: { from: string; to: string }[] = [];
|
| 1568 |
+
for (const row of rows) {
|
| 1569 |
+
const from = origin.get(row.id);
|
| 1570 |
+
const to = row.label.trim();
|
| 1571 |
+
if (!from || !to || from === to) continue;
|
| 1572 |
+
if (existing.has(to.toLowerCase())) continue;
|
| 1573 |
+
out.push({ from, to });
|
| 1574 |
+
}
|
| 1575 |
+
return out;
|
| 1576 |
+
}
|
| 1577 |
+
|
| 1578 |
+
/**
|
| 1579 |
+
* ⭐ Wave-20 owner item 14 — **IS THIS SELECTION EVENT'S ROW SET AUTHORITATIVE, or is it glide
|
| 1580 |
+
* clearing the checkboxes because you clicked a cell?**
|
| 1581 |
+
*
|
| 1582 |
+
* glide's selection is EXCLUSIVE by default: `useSelectionBehavior.setCurrent` (its own
|
| 1583 |
+
* `internal/data-grid/use-selection-behavior.js`) builds every cell-click event with
|
| 1584 |
+
* `rows: CompactSelection.empty()`, and `rowSelectionBlending="mixed"` does NOT change that —
|
| 1585 |
+
* the flag is gated behind `rangeMixable`, which needs `append || trigger === "drag"`, so a
|
| 1586 |
+
* plain click empties the rows whatever props we pass. Translating that emptiness into
|
| 1587 |
+
* `selectedPids` is what un-ticked the checked rows the moment the user clicked another
|
| 1588 |
+
* customer's name: tick four customers for a cohort, click the fifth to read its row, keep one.
|
| 1589 |
+
*
|
| 1590 |
+
* The discriminator is `current`. A ROW-marker interaction comes through `setSelectedRows`,
|
| 1591 |
+
* which sets `current: undefined` in BOTH of its branches; a CELL interaction always carries a
|
| 1592 |
+
* `current`. So:
|
| 1593 |
+
* current === undefined -> a row/marker event (or a genuine clear: Escape, void click) -> ADOPT
|
| 1594 |
+
* rows.length > 0 -> the rows were positively set -> ADOPT (dead under "exclusive",
|
| 1595 |
+
* kept because it is the property meant, not the proxy)
|
| 1596 |
+
* otherwise -> a cell click that emptied the rows -> KEEP the pids
|
| 1597 |
+
*
|
| 1598 |
+
* ⚠ IT LIVES HERE, NOT IN `useGridSelection`, FOR ONE REASON: that module imports glide, and
|
| 1599 |
+
* glide cannot be loaded under node — its CJS build trips over the package's `"type":"module"`
|
| 1600 |
+
* (measured: `ReferenceError: exports is not defined in ES module scope`). A predicate inside it
|
| 1601 |
+
* is therefore ungateable, and there is no vitest here. types.ts imports nothing but `windows`,
|
| 1602 |
+
* so the gate exercises the SHIPPED function rather than a copy ([[gate-negative-control]]).
|
| 1603 |
+
*
|
| 1604 |
+
* ⚠ Typed structurally rather than as glide's `GridSelection` for the same reason — a real
|
| 1605 |
+
* `GridSelection` satisfies it (`CompactSelection` exposes `length`).
|
| 1606 |
+
*/
|
| 1607 |
+
export function rowsAuthoritative(sel: {
|
| 1608 |
+
current?: unknown;
|
| 1609 |
+
rows: { length: number };
|
| 1610 |
+
}): boolean {
|
| 1611 |
+
return sel.current === undefined || sel.rows.length > 0;
|
| 1612 |
+
}
|
| 1613 |
+
|
| 1614 |
+
/**
|
| 1615 |
+
* ⭐ Wave-20 owner item 2 — **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
|
| 1616 |
+
* `rule.colId`.**
|
| 1617 |
+
*
|
| 1618 |
+
* A FORMULA-MEASURE column (`{measure:{key, window}}`, owner item 7) is `filterable:false`;
|
| 1619 |
+
* its filter REPLACEMENT is a measure CONDITION carrying the same measure and window. That
|
| 1620 |
+
* condition's `colId` is the MEASURE key ("revenue"), while a SORT on the very same column is
|
| 1621 |
+
* written with the COLUMN key (`measure_sales_90d_…`, from `ColumnMenu`'s `onSort`). Two keys,
|
| 1622 |
+
* one column — so every consumer that asked "does the filter tree name this column?" by string
|
| 1623 |
+
* equality answered NO for a column the user had visibly filtered:
|
| 1624 |
+
*
|
| 1625 |
+
* · the tint (`useGridColumns.columnTones`) fell through to the SORT hue, which is owner
|
| 1626 |
+
* item 2 exactly: filter > sort, violated, on the one column class where the two keys
|
| 1627 |
+
* differ. MEASURED — `_qa_wave20_tone.py` reproduced it as the only failing combination
|
| 1628 |
+
* in a 26-check sweep, with the condition provably narrowing 40 rows to 38.
|
| 1629 |
+
* · the column menu's "Don't filter by this field" (`treeNamesField`) never offered itself
|
| 1630 |
+
* on a measure column, and would have dropped nothing if it had.
|
| 1631 |
+
*
|
| 1632 |
+
* ⚠ The window is part of the identity, not decoration. Two columns can display the SAME
|
| 1633 |
+
* measure over different windows; a condition on `last 90 days` is about the 90-day column and
|
| 1634 |
+
* not about the LTM one beside it. `normalizeWindow` first, so a saved view whose window says
|
| 1635 |
+
* `{kind:'last_n_days', n:'90'}` (a string, from JSON) still matches the column that declares
|
| 1636 |
+
* `n: 90`.
|
| 1637 |
+
*
|
| 1638 |
+
* ⚠ NO GUESSING when nothing matches: a measure condition whose measure is not displayed as a
|
| 1639 |
+
* column returns NO keys, so the toolbar chip carries it alone — which is correct and is what
|
| 1640 |
+
* the pre-measure code did by accident. Cohort leaves (`__cohort__`) are deliberately in the
|
| 1641 |
+
* same boat: the derived cohort column's key is chosen HOST-side and the client has no constant
|
| 1642 |
+
* for it, and a tint on the wrong column is worse than no tint.
|
| 1643 |
+
*/
|
| 1644 |
+
export function windowKey(spec: unknown): string {
|
| 1645 |
+
const w = normalizeWindow(spec);
|
| 1646 |
+
return w ? `${w.kind}|${w.n ?? ""}|${w.from ?? ""}|${w.to ?? ""}` : "";
|
| 1647 |
+
}
|
| 1648 |
+
|
| 1649 |
+
/** `measureKey|windowKey` -> every column key DISPLAYING that measure over that window
|
| 1650 |
+
* (a duplicated column means two, and both are about the same condition). */
|
| 1651 |
+
export function measureColumnIndex(fields: Field[]): Map<string, string[]> {
|
| 1652 |
+
const out = new Map<string, string[]>();
|
| 1653 |
+
for (const field of fields ?? []) {
|
| 1654 |
+
if (!field.measure) continue;
|
| 1655 |
+
const k = `${field.measure.key}|${windowKey(field.measure.window)}`;
|
| 1656 |
+
const at = out.get(k);
|
| 1657 |
+
if (at) at.push(field.key);
|
| 1658 |
+
else out.set(k, [field.key]);
|
| 1659 |
+
}
|
| 1660 |
+
return out;
|
| 1661 |
+
}
|
| 1662 |
+
|
| 1663 |
+
/**
|
| 1664 |
+
* THE resolution, and the only one any consumer may use: the column keys this rule is about.
|
| 1665 |
+
* A measure condition (the presence of `window` is the discriminator — see FilterRule.window)
|
| 1666 |
+
* resolves through the index; every other rule is about its own `colId`.
|
| 1667 |
+
*/
|
| 1668 |
+
export function ruleColumnKeys(
|
| 1669 |
+
rule: FilterRule,
|
| 1670 |
+
measureCols?: Map<string, string[]>
|
| 1671 |
+
): string[] {
|
| 1672 |
+
// `__cohort__` is a SENTINEL, not a column key. Returned as-is it would put a key in the
|
| 1673 |
+
// tone map that no column can ever match — harmless on screen, but it makes "every key in
|
| 1674 |
+
// this map is a column" false, and that is the invariant the gate checks.
|
| 1675 |
+
if (rule.colId === COHORT_FIELD) return [];
|
| 1676 |
+
if (rule.window)
|
| 1677 |
+
return measureCols?.get(`${rule.colId}|${windowKey(rule.window)}`) ?? [];
|
| 1678 |
+
return [rule.colId];
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
/**
|
| 1682 |
* CG-8 — a measure the condition builder may offer: FILTERABLE BUT NEVER DISPLAYABLE.
|
| 1683 |
*
|
|
|
|
| 2544 |
* global, so an old bundle keeps today's behavior byte-for-byte.
|
| 2545 |
*/
|
| 2546 |
| { id: string; type: "field_upsert"; field: Field; scope?: FieldScope }
|
| 2547 |
+
/**
|
| 2548 |
+
* ⭐ Wave-20 owner item 15 / contract C-RENAME — **a choice was RENAMED, and here is the
|
| 2549 |
+
* mapping.** Emitted BESIDE the `field_upsert` that changes the declared list, never instead
|
| 2550 |
+
* of it: the definition and the values are two stores, and only the definition can be written
|
| 2551 |
+
* optimistically.
|
| 2552 |
+
*
|
| 2553 |
+
* The host rewrites (S1's half): every overlay VALUE holding `from`, and every saved view
|
| 2554 |
+
* that names it in a filter or a colour-by. That second half is not tidiness — a view
|
| 2555 |
+
* filtering `Stage is Blue` after Blue became Navy silently matches nothing, which looks
|
| 2556 |
+
* exactly like a view whose records all went away.
|
| 2557 |
+
*
|
| 2558 |
+
* ⚠ EXPLICIT, never a diff. "Renamed Blue to Navy" and "deleted Blue, added Navy" produce
|
| 2559 |
+
* the same pair of lists; the client knows which happened because its option rows carry
|
| 2560 |
+
* stable ids, and this event is how that knowledge crosses the wire (`ColumnMenu`'s
|
| 2561 |
+
* `optionRenames`). One event per SAVE, carrying every rename in it, so the migration is one
|
| 2562 |
+
* pass over the values rather than one per option.
|
| 2563 |
+
*/
|
| 2564 |
+
| {
|
| 2565 |
+
id: string;
|
| 2566 |
+
type: "choice_rename";
|
| 2567 |
+
key: string;
|
| 2568 |
+
renames: { from: string; to: string }[];
|
| 2569 |
+
scope?: FieldScope;
|
| 2570 |
+
}
|
| 2571 |
/**
|
| 2572 |
* Wave-5 item 1 — Duplicate field (contract AMENDMENT ~20:20: the CLIENT generates the
|
| 2573 |
* destination `key`, same prefix as `sourceKey` — the established key-generation pattern —
|
web/src/customer-grid/undoStack.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / undoStack.ts
|
| 3 |
+
// ⭐ Wave-20 owner item 16 (ruling R4, contract C-UNDO) — Ctrl+Z for grid DATA.
|
| 4 |
+
//
|
| 5 |
+
// THE SHAPE, and why it is a module of its own rather than a hook.
|
| 6 |
+
//
|
| 7 |
+
// The contract says "inverse ops recorded at the write layer (optimism.ts /
|
| 8 |
+
// apiBridge.ts)". Neither of those can actually hold it: `optimism.ts` is pure
|
| 9 |
+
// reconciliation with no write functions, and `apiBridge.patchTopicRow` never
|
| 10 |
+
// sees the value a cell held BEFORE the edit — by the time the PATCH is built,
|
| 11 |
+
// the old value is gone. **An inverse can only be captured at the call site**,
|
| 12 |
+
// where both halves are still in hand. So the recording happens in
|
| 13 |
+
// `CustomerGrid.patchAndRecord` (one wrapper over the single `patchOverlay`
|
| 14 |
+
// door every cell write already goes through), and THIS module owns the part
|
| 15 |
+
// that can be reasoned about on its own: the stack, the bounds, the
|
| 16 |
+
// fork-on-new-write rule, and what the inverse of each op IS.
|
| 17 |
+
//
|
| 18 |
+
// It imports nothing. That is deliberate and it is the only way this is
|
| 19 |
+
// gateable: there is no vitest here, gates are `tsc → node` over a `_test/*.ts`,
|
| 20 |
+
// and anything that pulls in React or glide cannot be loaded under node at all
|
| 21 |
+
// (measured: glide's CJS build trips over its own `"type":"module"`).
|
| 22 |
+
//
|
| 23 |
+
// R4 SCOPE — grid DATA only: cell edits, paste, bulk clear, select/multiselect
|
| 24 |
+
// value changes, ut row add/delete. NOT view config (filters, sorts, column
|
| 25 |
+
// widths), NOT field definitions, NOT cohort membership. Those are governed by
|
| 26 |
+
// their own echo/persistence rules and an undo that silently reverted a saved
|
| 27 |
+
// view would be a second writer of the same state.
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
/** What a cell can hold on the wire. `null` is a real value (cleared), not "no entry". */
|
| 31 |
+
export type UndoValue = string | number | null;
|
| 32 |
+
|
| 33 |
+
export interface CellChange {
|
| 34 |
+
pid: number;
|
| 35 |
+
key: string;
|
| 36 |
+
before: UndoValue;
|
| 37 |
+
after: UndoValue;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* One user action. A single entry can carry MANY cells — that is what makes a paste or a
|
| 42 |
+
* bulk Backspace one Ctrl+Z instead of forty (R4: "bulk Backspace = ONE grouped stack entry").
|
| 43 |
+
*/
|
| 44 |
+
export type UndoEntry =
|
| 45 |
+
| { kind: "cells"; label: string; changes: CellChange[] }
|
| 46 |
+
/** A ut row that was APPENDED. `values` is what it held, so redo can restore it verbatim. */
|
| 47 |
+
| { kind: "rowAdd"; table: string; rid: string | number; values: Record<string, UndoValue> }
|
| 48 |
+
/** A ut row that was DELETED, with the full row so the undo can put it back under its id. */
|
| 49 |
+
| { kind: "rowDelete"; table: string; rid: string | number; values: Record<string, UndoValue> }
|
| 50 |
+
/** Item 15 — an explicit choice rename; the inverse is the mapping turned around. */
|
| 51 |
+
| { kind: "choiceRename"; fieldKey: string; renames: { from: string; to: string }[] };
|
| 52 |
+
|
| 53 |
+
export interface UndoState {
|
| 54 |
+
past: UndoEntry[];
|
| 55 |
+
future: UndoEntry[];
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* The stack's depth. Bounded because every entry holds the values it replaced: a session that
|
| 60 |
+
* pasted a thousand rows would otherwise keep every one of them alive for the life of the tab.
|
| 61 |
+
* Fifty user actions is far past the point where anyone reaches for Ctrl+Z rather than for the
|
| 62 |
+
* value they wanted.
|
| 63 |
+
*/
|
| 64 |
+
export const UNDO_MAX = 50;
|
| 65 |
+
|
| 66 |
+
export const EMPTY_UNDO: UndoState = { past: [], future: [] };
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Is this entry worth a stack slot?
|
| 70 |
+
*
|
| 71 |
+
* ⚠ A cell "edit" that changed nothing is the common case, not an edge case: glide commits an
|
| 72 |
+
* edit on every editor close, including one the user opened and left alone, and a bulk clear
|
| 73 |
+
* over rows that were already blank produces a whole selection of them. Recording those makes
|
| 74 |
+
* Ctrl+Z do nothing visible — which reads as "undo is broken" rather than "there was nothing to
|
| 75 |
+
* undo". Compared as STRINGS because the overlay stratum stores strings and a number that has
|
| 76 |
+
* been through the store comes back as one (the `reconcileCellJournal` rule).
|
| 77 |
+
*/
|
| 78 |
+
export function meaningful(entry: UndoEntry): boolean {
|
| 79 |
+
if (entry.kind !== "cells") return true;
|
| 80 |
+
return entry.changes.some((c) => String(c.before ?? "") !== String(c.after ?? ""));
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Drop the no-op cells, so an entry that survives is entirely made of real changes. */
|
| 84 |
+
export function pruned(entry: UndoEntry): UndoEntry {
|
| 85 |
+
if (entry.kind !== "cells") return entry;
|
| 86 |
+
return {
|
| 87 |
+
...entry,
|
| 88 |
+
changes: entry.changes.filter((c) => String(c.before ?? "") !== String(c.after ?? "")),
|
| 89 |
+
};
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Record a new action.
|
| 94 |
+
*
|
| 95 |
+
* ⚠ IT CLEARS `future`, and that is the standard undo law rather than a simplification: once
|
| 96 |
+
* you undo three edits and then type something new, the three you undid are no longer reachable
|
| 97 |
+
* — a redo after a fork would apply a change on top of a state it was never computed against.
|
| 98 |
+
*/
|
| 99 |
+
export function pushUndo(state: UndoState, entry: UndoEntry): UndoState {
|
| 100 |
+
const clean = pruned(entry);
|
| 101 |
+
if (!meaningful(clean)) return state;
|
| 102 |
+
return { past: [...state.past, clean].slice(-UNDO_MAX), future: [] };
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/** The entry to apply BACKWARD, and the state that remembers it for redo. */
|
| 106 |
+
export function popUndo(state: UndoState): { state: UndoState; entry: UndoEntry | null } {
|
| 107 |
+
// `[length - 1]`, not `.at(-1)`: the gates compile this module at es2020, where
|
| 108 |
+
// `Array.prototype.at` is not in the lib — and a module a gate cannot compile is a
|
| 109 |
+
// module with no gate.
|
| 110 |
+
const entry = state.past[state.past.length - 1];
|
| 111 |
+
if (!entry) return { state, entry: null };
|
| 112 |
+
return {
|
| 113 |
+
state: { past: state.past.slice(0, -1), future: [...state.future, entry].slice(-UNDO_MAX) },
|
| 114 |
+
entry,
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/** The entry to apply FORWARD again. */
|
| 119 |
+
export function popRedo(state: UndoState): { state: UndoState; entry: UndoEntry | null } {
|
| 120 |
+
const entry = state.future[state.future.length - 1];
|
| 121 |
+
if (!entry) return { state, entry: null };
|
| 122 |
+
return {
|
| 123 |
+
state: { past: [...state.past, entry].slice(-UNDO_MAX), future: state.future.slice(0, -1) },
|
| 124 |
+
entry,
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* The entry as it must be applied in the given direction — the ONE place the inverse of each op
|
| 130 |
+
* is written down.
|
| 131 |
+
*
|
| 132 |
+
* `"back"` is what Ctrl+Z applies; `"forward"` is the redo. For cells the two differ only in
|
| 133 |
+
* which side of the change is written, which is why `CellChange` keeps both rather than storing
|
| 134 |
+
* a "delta" that would have to be re-derived (and could be re-derived wrongly).
|
| 135 |
+
*/
|
| 136 |
+
export function directed(
|
| 137 |
+
entry: UndoEntry,
|
| 138 |
+
dir: "back" | "forward"
|
| 139 |
+
): UndoEntry {
|
| 140 |
+
if (dir === "forward") return entry;
|
| 141 |
+
switch (entry.kind) {
|
| 142 |
+
case "cells":
|
| 143 |
+
return {
|
| 144 |
+
...entry,
|
| 145 |
+
changes: entry.changes.map((c) => ({ ...c, before: c.after, after: c.before })),
|
| 146 |
+
};
|
| 147 |
+
// Undoing an APPEND deletes the row; undoing a DELETE puts it back. The pair is symmetric,
|
| 148 |
+
// so inverting the kind is the whole of it — and the `values` ride along either way, because
|
| 149 |
+
// a restore needs them and a delete has to keep them for its own undo.
|
| 150 |
+
case "rowAdd":
|
| 151 |
+
return { ...entry, kind: "rowDelete" };
|
| 152 |
+
case "rowDelete":
|
| 153 |
+
return { ...entry, kind: "rowAdd" };
|
| 154 |
+
case "choiceRename":
|
| 155 |
+
return {
|
| 156 |
+
...entry,
|
| 157 |
+
renames: entry.renames.map((r) => ({ from: r.to, to: r.from })),
|
| 158 |
+
};
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/** What to tell the user. Short, and always a COUNT — "Undid an edit" on a 40-cell paste is a
|
| 163 |
+
* sentence that makes them check what happened. */
|
| 164 |
+
export function describe(entry: UndoEntry, dir: "back" | "forward"): string {
|
| 165 |
+
const verb = dir === "back" ? "Undid" : "Redid";
|
| 166 |
+
switch (entry.kind) {
|
| 167 |
+
case "cells": {
|
| 168 |
+
const n = entry.changes.length;
|
| 169 |
+
return `${verb} ${n === 1 ? entry.label : `${entry.label} (${n} cells)`}`;
|
| 170 |
+
}
|
| 171 |
+
case "rowAdd":
|
| 172 |
+
return `${verb} adding a record`;
|
| 173 |
+
case "rowDelete":
|
| 174 |
+
return `${verb} deleting a record`;
|
| 175 |
+
case "choiceRename": {
|
| 176 |
+
const n = entry.renames.length;
|
| 177 |
+
return `${verb} renaming ${n === 1 ? "an option" : `${n} options`}`;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/** Per-SCOPE stacks in one bag: the customer grid and a user table are different tables, and a
|
| 183 |
+
* Ctrl+Z on one must never rewrite a cell on the other. Per-TAB comes for free — this lives in
|
| 184 |
+
* component state and nothing persists it. */
|
| 185 |
+
export type UndoBook = Record<string, UndoState>;
|
| 186 |
+
|
| 187 |
+
export function stackFor(book: UndoBook, scope: string): UndoState {
|
| 188 |
+
return book[scope] ?? EMPTY_UNDO;
|
| 189 |
+
}
|
web/src/customer-grid/useGridColumns.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useCallback, useMemo } from "react";
|
| 2 |
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
-
import { isFilterGroup, isRuleActive } from "./types";
|
| 5 |
import { typeIconName } from "./iconShapes";
|
| 6 |
import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
|
| 7 |
import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
|
|
@@ -62,14 +62,22 @@ function hasInfoMark(field: Field): boolean {
|
|
| 62 |
|
| 63 |
/** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
|
| 64 |
* rules are skipped: a rule that is not narrowing anything must not tint a column as though
|
| 65 |
-
* it were — that is the same "it looks like it is working" lie the engine refuses to tell.
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
for (const node of nodes ?? []) {
|
| 68 |
if (isFilterGroup(node)) {
|
| 69 |
-
filteredKeys(node.children, out);
|
| 70 |
continue;
|
| 71 |
}
|
| 72 |
-
if (isRuleActive(node)) out.add(
|
| 73 |
}
|
| 74 |
}
|
| 75 |
|
|
@@ -107,12 +115,19 @@ function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined
|
|
| 107 |
whole-table band; the band was its only consumer, so R5 took both. That left the surviving
|
| 108 |
precedence — the one that still paints — asserted by nothing, because `verify_icons` was
|
| 109 |
testing the twin. Exported so the gate can reach the function that is actually on screen. */
|
| 110 |
-
export function columnTones(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
const out = new Map<string, ControlTone>();
|
| 112 |
if (config.groupBy) out.set(config.groupBy, "group");
|
| 113 |
for (const s of config.sorts ?? []) out.set(s.colId, "sort");
|
| 114 |
const filtered = new Set<string>();
|
| 115 |
-
filteredKeys(config.filters ?? [], filtered);
|
| 116 |
for (const key of filtered) out.set(key, "filter");
|
| 117 |
return out;
|
| 118 |
}
|
|
@@ -199,9 +214,11 @@ export function useGridColumns(
|
|
| 199 |
);
|
| 200 |
|
| 201 |
// Item 22 — recomputed only when the three controls move, not on every width drag.
|
|
|
|
|
|
|
| 202 |
const tones = useMemo(
|
| 203 |
-
() => columnTones(config),
|
| 204 |
-
[config.filters, config.sorts, config.groupBy] // eslint-disable-line react-hooks/exhaustive-deps
|
| 205 |
);
|
| 206 |
|
| 207 |
const visibleCols = useMemo<GridColumn[]>(
|
|
|
|
| 1 |
import { useCallback, useMemo } from "react";
|
| 2 |
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
+
import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
|
| 5 |
import { typeIconName } from "./iconShapes";
|
| 6 |
import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
|
| 7 |
import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
|
|
|
|
| 62 |
|
| 63 |
/** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
|
| 64 |
* rules are skipped: a rule that is not narrowing anything must not tint a column as though
|
| 65 |
+
* it were — that is the same "it looks like it is working" lie the engine refuses to tell.
|
| 66 |
+
*
|
| 67 |
+
* ⚠ Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT — a measure
|
| 68 |
+
* condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
|
| 69 |
+
* is what stopped a filtered-and-sorted measure column wearing the sort hue. */
|
| 70 |
+
function filteredKeys(
|
| 71 |
+
nodes: FilterNode[],
|
| 72 |
+
out: Set<string>,
|
| 73 |
+
measureCols: Map<string, string[]>
|
| 74 |
+
): void {
|
| 75 |
for (const node of nodes ?? []) {
|
| 76 |
if (isFilterGroup(node)) {
|
| 77 |
+
filteredKeys(node.children, out, measureCols);
|
| 78 |
continue;
|
| 79 |
}
|
| 80 |
+
if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
|
| 81 |
}
|
| 82 |
}
|
| 83 |
|
|
|
|
| 115 |
whole-table band; the band was its only consumer, so R5 took both. That left the surviving
|
| 116 |
precedence — the one that still paints — asserted by nothing, because `verify_icons` was
|
| 117 |
testing the twin. Exported so the gate can reach the function that is actually on screen. */
|
| 118 |
+
export function columnTones(
|
| 119 |
+
config: ViewConfig,
|
| 120 |
+
/* wave20 item 2 — the FIELDS, because a measure condition names its measure and only the
|
| 121 |
+
field list can say which column displays it. Defaulted so the signature stays callable
|
| 122 |
+
with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
|
| 123 |
+
legitimately has no measure columns to resolve. */
|
| 124 |
+
fields: Field[] = []
|
| 125 |
+
): Map<string, ControlTone> {
|
| 126 |
const out = new Map<string, ControlTone>();
|
| 127 |
if (config.groupBy) out.set(config.groupBy, "group");
|
| 128 |
for (const s of config.sorts ?? []) out.set(s.colId, "sort");
|
| 129 |
const filtered = new Set<string>();
|
| 130 |
+
filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
|
| 131 |
for (const key of filtered) out.set(key, "filter");
|
| 132 |
return out;
|
| 133 |
}
|
|
|
|
| 214 |
);
|
| 215 |
|
| 216 |
// Item 22 — recomputed only when the three controls move, not on every width drag.
|
| 217 |
+
// Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
|
| 218 |
+
// displays it, and a new measure column must re-tint without waiting for a control to move.
|
| 219 |
const tones = useMemo(
|
| 220 |
+
() => columnTones(config, fields),
|
| 221 |
+
[config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
|
| 222 |
);
|
| 223 |
|
| 224 |
const visibleCols = useMemo<GridColumn[]>(
|
web/src/customer-grid/useGridSelection.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
| 19 |
import { CompactSelection } from "@glideapps/glide-data-grid";
|
| 20 |
import type { GridSelection, Item } from "@glideapps/glide-data-grid";
|
| 21 |
import type { VisibleRow } from "./types";
|
|
|
|
| 22 |
|
| 23 |
export interface GridSelectionApi {
|
| 24 |
gridSelection: GridSelection;
|
|
@@ -99,6 +100,9 @@ export function useGridSelection(
|
|
| 99 |
// follows across row-pipeline rebuilds.
|
| 100 |
const at = sel.current ? visibleRows[sel.current.cell[1]] : undefined;
|
| 101 |
currentPidRef.current = at && at.kind === "data" ? at.record.pid : null;
|
|
|
|
|
|
|
|
|
|
| 102 |
// Translate the new row index-set back to pids (guard synthetic rows).
|
| 103 |
const pids = new Set<number>();
|
| 104 |
for (const i of sel.rows) {
|
|
|
|
| 19 |
import { CompactSelection } from "@glideapps/glide-data-grid";
|
| 20 |
import type { GridSelection, Item } from "@glideapps/glide-data-grid";
|
| 21 |
import type { VisibleRow } from "./types";
|
| 22 |
+
import { rowsAuthoritative } from "./types";
|
| 23 |
|
| 24 |
export interface GridSelectionApi {
|
| 25 |
gridSelection: GridSelection;
|
|
|
|
| 100 |
// follows across row-pipeline rebuilds.
|
| 101 |
const at = sel.current ? visibleRows[sel.current.cell[1]] : undefined;
|
| 102 |
currentPidRef.current = at && at.kind === "data" ? at.record.pid : null;
|
| 103 |
+
// Owner item 14 — the active cell moves; the CHECKED ROWS do not, unless this event is
|
| 104 |
+
// actually about them. See `rowsAuthoritative`.
|
| 105 |
+
if (!rowsAuthoritative(sel)) return;
|
| 106 |
// Translate the new row index-set back to pids (guard synthetic rows).
|
| 107 |
const pids = new Set<number>();
|
| 108 |
for (const i of sel.rows) {
|
web/src/customer-grid/useVisibleRows.ts
CHANGED
|
@@ -580,7 +580,12 @@ function groupRows(
|
|
| 580 |
};
|
| 581 |
for (const r of rows) {
|
| 582 |
const raw = r[groupBy];
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
const keys = isMulti
|
| 585 |
? cell.split(",").map((s) => s.trim()).filter((s) => s !== "")
|
| 586 |
: [cell];
|
|
|
|
| 580 |
};
|
| 581 |
for (const r of rows) {
|
| 582 |
const raw = r[groupBy];
|
| 583 |
+
// ⭐ Wave-20 item 11 — TRIMMED. User-created text columns are groupable now, and typed text
|
| 584 |
+
// carries stray spaces: "Miami" and "Miami " are the same bucket to a reader and were two
|
| 585 |
+
// groups here, one of which looked empty-labelled. The multi path below already trimmed its
|
| 586 |
+
// members; this is the single-value half of the same rule. A cell of only spaces trims to
|
| 587 |
+
// "" and joins "(empty)", which is what it looks like on screen.
|
| 588 |
+
const cell = raw == null ? "" : String(raw).trim();
|
| 589 |
const keys = isMulti
|
| 590 |
? cell.split(",").map((s) => s.trim()).filter((s) => s !== "")
|
| 591 |
: [cell];
|
web/src/index.css
CHANGED
|
@@ -8199,106 +8199,637 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 8199 |
}
|
| 8200 |
}
|
| 8201 |
/* == /W19-D == */
|
| 8202 |
-
|
| 8203 |
-
/* == EXIT-6 STATEMENTS == */
|
| 8204 |
-
/* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
|
| 8205 |
-
`app.py::_collections_statements` when Streamlit was deleted.
|
| 8206 |
-
|
| 8207 |
-
⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
|
| 8208 |
-
removed in wave 17 (R7) and its rules deliberately went with it, with a note
|
| 8209 |
-
in this file saying a dead rule with a plausible name is worse than no rule
|
| 8210 |
-
because the next thing needing a table adopts decisions nobody made for it.
|
| 8211 |
-
This IS that next thing, so it declares its own, named for its one purpose.
|
| 8212 |
-
|
| 8213 |
-
Quiet by intent: this pane ends in a control that emails real customers, so
|
| 8214 |
-
nothing here competes with the confirm block for attention. */
|
| 8215 |
-
.stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
|
| 8216 |
-
.stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
|
| 8217 |
-
.stmt-small { margin-top: 6px; }
|
| 8218 |
-
.stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
| 8219 |
-
.stmt-wide { width: 100%; }
|
| 8220 |
-
.stmt-link {
|
| 8221 |
-
margin-top: 14px;
|
| 8222 |
-
padding: 0;
|
| 8223 |
-
border: 0;
|
| 8224 |
-
background: none;
|
| 8225 |
-
color: var(--lp-blue-deep);
|
| 8226 |
-
font: inherit;
|
| 8227 |
-
font-size: var(--lp-fs-2xs);
|
| 8228 |
-
font-weight: 600;
|
| 8229 |
-
cursor: pointer;
|
| 8230 |
-
text-align: left;
|
| 8231 |
-
}
|
| 8232 |
-
.stmt-textarea {
|
| 8233 |
-
width: 100%;
|
| 8234 |
-
padding: 8px 10px;
|
| 8235 |
-
border: 1px solid var(--lp-line);
|
| 8236 |
-
border-radius: var(--lp-r-sm);
|
| 8237 |
-
background: var(--lp-surface);
|
| 8238 |
-
color: var(--lp-ink);
|
| 8239 |
-
font: inherit;
|
| 8240 |
-
font-size: var(--lp-fs-xs);
|
| 8241 |
-
line-height: var(--lp-lh);
|
| 8242 |
-
resize: vertical;
|
| 8243 |
-
}
|
| 8244 |
-
/* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
|
| 8245 |
-
the settings modal must not grow a second scrollbar for it. */
|
| 8246 |
-
.stmt-tablewrap {
|
| 8247 |
-
margin-top: 12px;
|
| 8248 |
-
max-height: 340px;
|
| 8249 |
-
overflow: auto;
|
| 8250 |
-
border: 1px solid var(--lp-line);
|
| 8251 |
-
border-radius: var(--lp-r-sm);
|
| 8252 |
-
}
|
| 8253 |
-
.stmt-tablewrap--short { max-height: 200px; }
|
| 8254 |
-
.stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
|
| 8255 |
-
.stmt-table th,
|
| 8256 |
-
.stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
|
| 8257 |
-
/* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
|
| 8258 |
-
.stmt-table thead th {
|
| 8259 |
-
position: sticky;
|
| 8260 |
-
top: 0;
|
| 8261 |
-
z-index: 1;
|
| 8262 |
-
background: var(--lp-surface-2);
|
| 8263 |
-
font-weight: 620;
|
| 8264 |
-
}
|
| 8265 |
-
.stmt-table tbody tr:last-child td { border-bottom: 0; }
|
| 8266 |
-
.stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
|
| 8267 |
-
/* Money aligns right, everywhere in this product. */
|
| 8268 |
-
.stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
|
| 8269 |
-
.stmt-table th.stmt-num { text-align: right; }
|
| 8270 |
-
/* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
|
| 8271 |
-
banner is neither success nor failure — it is a live restriction, and dressing
|
| 8272 |
-
a guardrail in green is how somebody reads "ON" as "you are clear to send". */
|
| 8273 |
-
.stmt-warn {
|
| 8274 |
-
margin: 0 0 12px;
|
| 8275 |
-
padding: 9px 12px;
|
| 8276 |
-
border-radius: var(--lp-r-sm);
|
| 8277 |
-
background: var(--lp-yellow-tint);
|
| 8278 |
-
color: var(--lp-yellow-deep);
|
| 8279 |
-
font-size: var(--lp-fs-2xs);
|
| 8280 |
-
line-height: var(--lp-lh);
|
| 8281 |
-
}
|
| 8282 |
-
.stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
|
| 8283 |
-
.stmt-details summary { cursor: pointer; font-weight: 600; }
|
| 8284 |
-
/* The rendered statement is OUR html from OUR template, but it is still foreign
|
| 8285 |
-
markup inside a pane: box it so its own font sizes cannot reflow the modal. */
|
| 8286 |
-
.stmt-preview {
|
| 8287 |
-
margin-top: 10px;
|
| 8288 |
-
padding: 12px;
|
| 8289 |
-
max-height: 320px;
|
| 8290 |
-
overflow: auto;
|
| 8291 |
-
border: 1px solid var(--lp-line);
|
| 8292 |
-
border-radius: var(--lp-r-sm);
|
| 8293 |
-
background: var(--lp-surface);
|
| 8294 |
-
}
|
| 8295 |
-
.stmt-preview img { max-width: 100%; }
|
| 8296 |
-
/* The confirm block is the loudest thing on the pane, on purpose. */
|
| 8297 |
-
.stmt-confirm {
|
| 8298 |
-
margin-top: 16px;
|
| 8299 |
-
padding: 12px;
|
| 8300 |
-
border: 1px solid var(--lp-line);
|
| 8301 |
-
border-radius: var(--lp-r-sm);
|
| 8302 |
-
background: var(--lp-surface-2);
|
| 8303 |
-
}
|
| 8304 |
-
.stmt-confirm .stmt-row { margin-top: 12px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8199 |
}
|
| 8200 |
}
|
| 8201 |
/* == /W19-D == */
|
| 8202 |
+
|
| 8203 |
+
/* == EXIT-6 STATEMENTS == */
|
| 8204 |
+
/* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
|
| 8205 |
+
`app.py::_collections_statements` when Streamlit was deleted.
|
| 8206 |
+
|
| 8207 |
+
⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
|
| 8208 |
+
removed in wave 17 (R7) and its rules deliberately went with it, with a note
|
| 8209 |
+
in this file saying a dead rule with a plausible name is worse than no rule
|
| 8210 |
+
because the next thing needing a table adopts decisions nobody made for it.
|
| 8211 |
+
This IS that next thing, so it declares its own, named for its one purpose.
|
| 8212 |
+
|
| 8213 |
+
Quiet by intent: this pane ends in a control that emails real customers, so
|
| 8214 |
+
nothing here competes with the confirm block for attention. */
|
| 8215 |
+
.stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
|
| 8216 |
+
.stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
|
| 8217 |
+
.stmt-small { margin-top: 6px; }
|
| 8218 |
+
.stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
| 8219 |
+
.stmt-wide { width: 100%; }
|
| 8220 |
+
.stmt-link {
|
| 8221 |
+
margin-top: 14px;
|
| 8222 |
+
padding: 0;
|
| 8223 |
+
border: 0;
|
| 8224 |
+
background: none;
|
| 8225 |
+
color: var(--lp-blue-deep);
|
| 8226 |
+
font: inherit;
|
| 8227 |
+
font-size: var(--lp-fs-2xs);
|
| 8228 |
+
font-weight: 600;
|
| 8229 |
+
cursor: pointer;
|
| 8230 |
+
text-align: left;
|
| 8231 |
+
}
|
| 8232 |
+
.stmt-textarea {
|
| 8233 |
+
width: 100%;
|
| 8234 |
+
padding: 8px 10px;
|
| 8235 |
+
border: 1px solid var(--lp-line);
|
| 8236 |
+
border-radius: var(--lp-r-sm);
|
| 8237 |
+
background: var(--lp-surface);
|
| 8238 |
+
color: var(--lp-ink);
|
| 8239 |
+
font: inherit;
|
| 8240 |
+
font-size: var(--lp-fs-xs);
|
| 8241 |
+
line-height: var(--lp-lh);
|
| 8242 |
+
resize: vertical;
|
| 8243 |
+
}
|
| 8244 |
+
/* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
|
| 8245 |
+
the settings modal must not grow a second scrollbar for it. */
|
| 8246 |
+
.stmt-tablewrap {
|
| 8247 |
+
margin-top: 12px;
|
| 8248 |
+
max-height: 340px;
|
| 8249 |
+
overflow: auto;
|
| 8250 |
+
border: 1px solid var(--lp-line);
|
| 8251 |
+
border-radius: var(--lp-r-sm);
|
| 8252 |
+
}
|
| 8253 |
+
.stmt-tablewrap--short { max-height: 200px; }
|
| 8254 |
+
.stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
|
| 8255 |
+
.stmt-table th,
|
| 8256 |
+
.stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
|
| 8257 |
+
/* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
|
| 8258 |
+
.stmt-table thead th {
|
| 8259 |
+
position: sticky;
|
| 8260 |
+
top: 0;
|
| 8261 |
+
z-index: 1;
|
| 8262 |
+
background: var(--lp-surface-2);
|
| 8263 |
+
font-weight: 620;
|
| 8264 |
+
}
|
| 8265 |
+
.stmt-table tbody tr:last-child td { border-bottom: 0; }
|
| 8266 |
+
.stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
|
| 8267 |
+
/* Money aligns right, everywhere in this product. */
|
| 8268 |
+
.stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
|
| 8269 |
+
.stmt-table th.stmt-num { text-align: right; }
|
| 8270 |
+
/* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
|
| 8271 |
+
banner is neither success nor failure — it is a live restriction, and dressing
|
| 8272 |
+
a guardrail in green is how somebody reads "ON" as "you are clear to send". */
|
| 8273 |
+
.stmt-warn {
|
| 8274 |
+
margin: 0 0 12px;
|
| 8275 |
+
padding: 9px 12px;
|
| 8276 |
+
border-radius: var(--lp-r-sm);
|
| 8277 |
+
background: var(--lp-yellow-tint);
|
| 8278 |
+
color: var(--lp-yellow-deep);
|
| 8279 |
+
font-size: var(--lp-fs-2xs);
|
| 8280 |
+
line-height: var(--lp-lh);
|
| 8281 |
+
}
|
| 8282 |
+
.stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
|
| 8283 |
+
.stmt-details summary { cursor: pointer; font-weight: 600; }
|
| 8284 |
+
/* The rendered statement is OUR html from OUR template, but it is still foreign
|
| 8285 |
+
markup inside a pane: box it so its own font sizes cannot reflow the modal. */
|
| 8286 |
+
.stmt-preview {
|
| 8287 |
+
margin-top: 10px;
|
| 8288 |
+
padding: 12px;
|
| 8289 |
+
max-height: 320px;
|
| 8290 |
+
overflow: auto;
|
| 8291 |
+
border: 1px solid var(--lp-line);
|
| 8292 |
+
border-radius: var(--lp-r-sm);
|
| 8293 |
+
background: var(--lp-surface);
|
| 8294 |
+
}
|
| 8295 |
+
.stmt-preview img { max-width: 100%; }
|
| 8296 |
+
/* The confirm block is the loudest thing on the pane, on purpose. */
|
| 8297 |
+
.stmt-confirm {
|
| 8298 |
+
margin-top: 16px;
|
| 8299 |
+
padding: 12px;
|
| 8300 |
+
border: 1px solid var(--lp-line);
|
| 8301 |
+
border-radius: var(--lp-r-sm);
|
| 8302 |
+
background: var(--lp-surface-2);
|
| 8303 |
+
}
|
| 8304 |
+
.stmt-confirm .stmt-row { margin-top: 12px; }
|
| 8305 |
+
|
| 8306 |
+
/* ==== wave20:S4 shell+rail ====================================================
|
| 8307 |
+
WAVE 20, SESSION S4 — the shell frame and the views rail. Owner items 4, 7,
|
| 8308 |
+
19, 20, 21, 22, 24 (+ 18/23/25/26 once S1 posts C-SHARE / C-ALERT).
|
| 8309 |
+
|
| 8310 |
+
⚠ Everything in this block is scoped under a class this session owns, or is a
|
| 8311 |
+
deliberate specificity correction NAMED as one. Nothing here is edited by any
|
| 8312 |
+
other session, and this session edits nothing outside it (the wave-17 scar:
|
| 8313 |
+
four sessions, one stylesheet, line numbers that moved under each other).
|
| 8314 |
+
============================================================================ */
|
| 8315 |
+
|
| 8316 |
+
/* ── Item 24 — a primary CTA that is only primary ON HOVER ──────────────────
|
| 8317 |
+
`.shell-newdb-actions button` (0,1,1) OUT-RANKS `.login-submit` (0,1,0), so
|
| 8318 |
+
every primary button inside a shell dialog rendered white-on-ink at REST and
|
| 8319 |
+
only turned purple under the pointer — the AI-assistant note's Close (owner
|
| 8320 |
+
item 24) and, from the same cause, the New-database dialog's "Create
|
| 8321 |
+
database". The fix is the specificity, not a new colour: this rule is (0,2,0),
|
| 8322 |
+
so it wins at rest, while `.login-submit:hover:not(:disabled)` (0,3,0) and
|
| 8323 |
+
`:focus-visible` still win over it — the button keeps ONE hover shade and one
|
| 8324 |
+
focus ring, published once at `.login-submit`.
|
| 8325 |
+
Tokens, never literals: `--shell-on-solid` is the MEASURED white for text on
|
| 8326 |
+
`--lp-primary` (7.71:1), declared by `.login-submit` itself and inherited here
|
| 8327 |
+
rather than re-typed as `#fff`. The row's unclassed buttons (Cancel) are
|
| 8328 |
+
untouched — a dialog has one primary. */
|
| 8329 |
+
.shell-newdb-actions .login-submit {
|
| 8330 |
+
color: var(--shell-on-solid);
|
| 8331 |
+
background: var(--lp-primary);
|
| 8332 |
+
border-color: var(--lp-primary);
|
| 8333 |
+
}
|
| 8334 |
+
|
| 8335 |
+
/* ── Item 21 — the folder row's "…" is ALWAYS visible ───────────────────────
|
| 8336 |
+
`.cg-view-more` ships `opacity: 0` and is revealed by `.cg-view-row:hover` /
|
| 8337 |
+
`.is-active`. A FOLDER head is not a `.cg-view-row`, so no rule ever revealed
|
| 8338 |
+
it: the folder's actions button was painted at zero opacity in every state but
|
| 8339 |
+
keyboard focus — present, clickable, invisible ([[ui-invisible-to-assertions]]
|
| 8340 |
+
in reverse). The count badge that sat beside it is deleted in the markup; this
|
| 8341 |
+
makes the control it was standing in front of actually appear. (0,2,0) over
|
| 8342 |
+
the base rule's (0,1,0). */
|
| 8343 |
+
.cg-fold-head .cg-view-more { opacity: 1; }
|
| 8344 |
+
|
| 8345 |
+
/* ── Item 22 — the folder menu's ARMED delete may use two lines ─────────────
|
| 8346 |
+
The one stated exception to R7's "a menu row is ONE LINE". Every other row in
|
| 8347 |
+
both menus is closed vocabulary that fits; this one is a sentence
|
| 8348 |
+
("Delete folder? Its views move to the top level."), and under `nowrap` with a
|
| 8349 |
+
260px panel it ran outside the box. Wrapping is the only option that keeps a
|
| 8350 |
+
confirmation readable — an ellipsised confirm asks the reader to agree to a
|
| 8351 |
+
sentence they cannot finish. Armed only, so the resting menu is unchanged. */
|
| 8352 |
+
.cg-view-menu button.is-armed { white-space: normal; }
|
| 8353 |
+
.cg-view-menu button.is-armed .cg-mi-text { overflow: visible; text-overflow: clip; }
|
| 8354 |
+
|
| 8355 |
+
/* ── Item 20 — the create flyout's SECOND step, in the SAME box ─────────────
|
| 8356 |
+
Step 2 (name + who-can-edit) now renders inside the same `AnchoredOverlay` as
|
| 8357 |
+
the type chooser. `.cg-view-create` carries its own border, background and
|
| 8358 |
+
margins for the rail-inline placement it used to have; inside the panel that
|
| 8359 |
+
would draw a second box inside the first. Geometry only — the form's own
|
| 8360 |
+
controls are unchanged, which is the point of the item. */
|
| 8361 |
+
.cg-create-flyout .cg-create-form {
|
| 8362 |
+
margin: 0;
|
| 8363 |
+
padding: 2px;
|
| 8364 |
+
border: 0;
|
| 8365 |
+
background: transparent;
|
| 8366 |
+
}
|
| 8367 |
+
.cg-create-flyout .cg-create-form > label:first-child {
|
| 8368 |
+
display: block;
|
| 8369 |
+
margin-bottom: 4px;
|
| 8370 |
+
font-size: var(--lp-fs-3xs);
|
| 8371 |
+
font-weight: 600;
|
| 8372 |
+
color: var(--lp-muted);
|
| 8373 |
+
}
|
| 8374 |
+
/* ⚠ THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
|
| 8375 |
+
Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
|
| 8376 |
+
left-aligned, borderless MENU ROWS — the panel's own row style applied to a form's
|
| 8377 |
+
buttons. Caught by READING the screenshot: every assertion about the step (geometry,
|
| 8378 |
+
focus, labels) was green while the two buttons looked like list items
|
| 8379 |
+
([[ui-invisible-to-assertions]]). These rules restate `.cg-btn`'s own declarations at a
|
| 8380 |
+
specificity that wins inside the panel; they are a copy of that rule, never a second
|
| 8381 |
+
opinion about what a button looks like. */
|
| 8382 |
+
.cg-create-flyout .cg-create-form .cg-btn {
|
| 8383 |
+
width: auto;
|
| 8384 |
+
min-height: 28px;
|
| 8385 |
+
padding: 0 9px;
|
| 8386 |
+
border: 1px solid var(--lp-line);
|
| 8387 |
+
border-radius: var(--lp-r-md);
|
| 8388 |
+
background: var(--cg-white);
|
| 8389 |
+
color: var(--cg-text);
|
| 8390 |
+
font-size: var(--lp-fs-2xs);
|
| 8391 |
+
text-align: center;
|
| 8392 |
+
}
|
| 8393 |
+
.cg-create-flyout .cg-create-form .cg-btn:hover:not(:disabled) { background: var(--cg-soft); }
|
| 8394 |
+
.cg-create-flyout .cg-create-form .cg-btn--primary {
|
| 8395 |
+
border-color: var(--lp-primary);
|
| 8396 |
+
background: var(--lp-primary);
|
| 8397 |
+
color: var(--cg-white);
|
| 8398 |
+
}
|
| 8399 |
+
.cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
|
| 8400 |
+
background: var(--lp-primary-hover);
|
| 8401 |
+
}
|
| 8402 |
+
/* The who-can-edit rows are `<label>`s, and `.cg-view-create label` (0,1,1) stacks every
|
| 8403 |
+
label in a create form into a COLUMN — so the radio sat above its own words rather than
|
| 8404 |
+
beside them. Pre-dates this wave (the same two rules met in the rail-inline form), but
|
| 8405 |
+
item 20 makes these three rows the whole content of a small panel, where a radio floating
|
| 8406 |
+
over its label is the first thing you see. Scoped to the flyout: nothing else that reads
|
| 8407 |
+
`.cg-view-create label` moves. */
|
| 8408 |
+
.cg-create-flyout .cg-perm-row {
|
| 8409 |
+
flex-direction: row;
|
| 8410 |
+
align-items: flex-start;
|
| 8411 |
+
gap: 9px;
|
| 8412 |
+
}
|
| 8413 |
+
|
| 8414 |
+
/* ── Item 19 (C-FOLDER-REORDER) — dragging a FOLDER to reorder ──────────────
|
| 8415 |
+
A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
|
| 8416 |
+
view drag, which carries `text/plain`; the indicator is an insertion RULE at
|
| 8417 |
+
the top edge of the folder you would land above, never a fill, because a
|
| 8418 |
+
filled folder already means "drop this view INTO me" (`.is-drop`). */
|
| 8419 |
+
.cg-fold.is-folddrag,
|
| 8420 |
+
.cg-fold-root.is-folddrag { opacity: 0.45; }
|
| 8421 |
+
.cg-fold.is-drop-above,
|
| 8422 |
+
.cg-fold-root.is-drop-above { box-shadow: inset 0 2px 0 0 var(--lp-primary); }
|
| 8423 |
+
/* ⛔ NO `.cg-fold-grip` RULE, because there is no grip — see the note at the folder head in
|
| 8424 |
+
ViewSidebar.tsx. A handle in flow moves the folder name off the measured indent this rail
|
| 8425 |
+
spent wave-10 item 2 getting right, and the gutter it could hide in belongs to the
|
| 8426 |
+
disclosure chevron. The head drags whole, exactly as the view rows under it already do. */
|
| 8427 |
+
|
| 8428 |
+
/* ── Item 4 (R8 / C-ADDROW) — the UNIVERSAL database header ─────────────────
|
| 8429 |
+
Every database wears the same header: a chip in a bold colour with a white
|
| 8430 |
+
mark, then the database's name (`reference/Airtable 6.png`). It replaces the
|
| 8431 |
+
`shell-ut-bar`, which existed on user tables ONLY and carried the "Add record"
|
| 8432 |
+
button that S3's trailing "+" row supersedes.
|
| 8433 |
+
⚠ THE HEIGHT CHAIN IS LOAD-BEARING: glide measures its parent, so a frame that
|
| 8434 |
+
forgets `min-height: 0` or `flex: 1 1 auto` gives the canvas 0px and paints a
|
| 8435 |
+
blank grid that looks like a working page. These three rules are the wave-18
|
| 8436 |
+
`.shell-ut-frame` chain, kept intact rather than re-derived. */
|
| 8437 |
+
.shell-db-frame {
|
| 8438 |
+
display: flex;
|
| 8439 |
+
flex-direction: column;
|
| 8440 |
+
width: 100%;
|
| 8441 |
+
height: 100%;
|
| 8442 |
+
min-height: 0;
|
| 8443 |
+
overflow: hidden;
|
| 8444 |
+
}
|
| 8445 |
+
.shell-db-frame > .shell-grid-host { flex: 1 1 auto; min-height: 0; height: auto; }
|
| 8446 |
+
.shell-db-head {
|
| 8447 |
+
display: flex;
|
| 8448 |
+
align-items: center;
|
| 8449 |
+
gap: 10px;
|
| 8450 |
+
flex: 0 0 auto;
|
| 8451 |
+
padding: 9px 16px;
|
| 8452 |
+
border-bottom: 1px solid var(--lp-line);
|
| 8453 |
+
background: var(--lp-surface);
|
| 8454 |
+
}
|
| 8455 |
+
/* The chip is the one place in the product that paints a mark ON a solid tone
|
| 8456 |
+
rather than beside one. Every pairing below is white on a `-deep` token, whose
|
| 8457 |
+
measured ratio is published beside its declaration: primary 7.71:1, ink
|
| 8458 |
+
15.42:1, yellow 5.61:1, green 5.52:1, red 5.35:1, blue 3.29:1 — the last is
|
| 8459 |
+
under the 4.5:1 TEXT bar and over the 3:1 bar for a graphical object, which is
|
| 8460 |
+
what a 16px glyph is. */
|
| 8461 |
+
.shell-db-chip {
|
| 8462 |
+
display: inline-flex;
|
| 8463 |
+
align-items: center;
|
| 8464 |
+
justify-content: center;
|
| 8465 |
+
flex: 0 0 auto;
|
| 8466 |
+
width: 26px;
|
| 8467 |
+
height: 26px;
|
| 8468 |
+
border-radius: var(--lp-r-md);
|
| 8469 |
+
background: var(--lp-primary);
|
| 8470 |
+
color: var(--shell-on-solid, #ffffff);
|
| 8471 |
+
}
|
| 8472 |
+
.shell-db-chip--neutral { background: var(--lp-ink); }
|
| 8473 |
+
.shell-db-chip--blue { background: var(--lp-blue-deep); }
|
| 8474 |
+
.shell-db-chip--green { background: var(--lp-green-deep); }
|
| 8475 |
+
.shell-db-chip--yellow { background: var(--lp-yellow-deep); }
|
| 8476 |
+
.shell-db-chip--red { background: var(--lp-red-deep); }
|
| 8477 |
+
/* `.shell-nav-icon` dims to 0.55 for the rail; on a solid chip that reads as a
|
| 8478 |
+
half-erased glyph. And `FolderMark` paints its own `-deep` stroke — legible on
|
| 8479 |
+
the white rail, invisible on its own tone — so inside the chip the mark takes
|
| 8480 |
+
the chip's ink, which is the measured white above. */
|
| 8481 |
+
.shell-db-chip .shell-nav-icon { opacity: 1; }
|
| 8482 |
+
.shell-db-chip .cg-folder-mark path { stroke: currentColor; }
|
| 8483 |
+
/* An `h1` — so the UA's own heading margins and 2em size have to be reset here, or the
|
| 8484 |
+
header row grows to 60px and the name lands off the chip's centre line. */
|
| 8485 |
+
.shell-db-name {
|
| 8486 |
+
margin: 0;
|
| 8487 |
+
min-width: 0;
|
| 8488 |
+
overflow: hidden;
|
| 8489 |
+
font-size: var(--lp-fs-md);
|
| 8490 |
+
font-weight: 650;
|
| 8491 |
+
color: var(--lp-ink);
|
| 8492 |
+
text-overflow: ellipsis;
|
| 8493 |
+
white-space: nowrap;
|
| 8494 |
+
}
|
| 8495 |
+
|
| 8496 |
+
/* ── Items 18 / 23 / 26 (R10, C-SHARE) — the access editor ──────────────────
|
| 8497 |
+
One dialog for a view, a folder and a database, in the shell's existing dialog
|
| 8498 |
+
family (`.shell-newdb`'s box, so a third modal does not introduce a third look).
|
| 8499 |
+
The rail's second mark is `.cg-view-shared`, mirroring `.cg-view-lock` token for
|
| 8500 |
+
token — they sit side by side on the same row and must read as one family. */
|
| 8501 |
+
.cg-view-shared {
|
| 8502 |
+
flex: 0 0 auto;
|
| 8503 |
+
display: inline-flex;
|
| 8504 |
+
align-items: center;
|
| 8505 |
+
margin-left: auto;
|
| 8506 |
+
padding-left: 4px;
|
| 8507 |
+
color: var(--lp-muted);
|
| 8508 |
+
}
|
| 8509 |
+
/* ⚠ When BOTH marks are present the second one must not claim the auto margin as
|
| 8510 |
+
well, or the pair splits across the row with the name stranded between them. */
|
| 8511 |
+
.cg-view-shared + .cg-view-lock { margin-left: 0; }
|
| 8512 |
+
.shell-share { width: 460px; }
|
| 8513 |
+
/* The one-line answer, above the rows that spell it out. Muted and small: it
|
| 8514 |
+
REPEATS what the list says, so it must not compete with it — its whole value is
|
| 8515 |
+
being readable before the eye reaches the rows. */
|
| 8516 |
+
.shell-share-summary {
|
| 8517 |
+
margin: 14px 0 0;
|
| 8518 |
+
color: var(--lp-muted);
|
| 8519 |
+
font-size: var(--lp-fs-2xs);
|
| 8520 |
+
font-weight: 600;
|
| 8521 |
+
}
|
| 8522 |
+
.shell-share-summary + .shell-share-list { margin-top: 6px; }
|
| 8523 |
+
.shell-share-wait { display: flex; justify-content: center; padding: 18px 0; }
|
| 8524 |
+
.shell-share-list {
|
| 8525 |
+
margin-top: 14px;
|
| 8526 |
+
border: 1px solid var(--lp-line);
|
| 8527 |
+
border-radius: var(--lp-r-md);
|
| 8528 |
+
overflow: hidden;
|
| 8529 |
+
}
|
| 8530 |
+
.shell-share-row {
|
| 8531 |
+
display: flex;
|
| 8532 |
+
align-items: center;
|
| 8533 |
+
gap: 8px;
|
| 8534 |
+
padding: 7px 10px;
|
| 8535 |
+
border-bottom: 1px solid var(--lp-line);
|
| 8536 |
+
font-size: var(--lp-fs-xs);
|
| 8537 |
+
}
|
| 8538 |
+
.shell-share-row:last-child { border-bottom: 0; }
|
| 8539 |
+
.shell-share-row.is-owner { background: var(--lp-wash); }
|
| 8540 |
+
.shell-share-who {
|
| 8541 |
+
flex: 1 1 auto;
|
| 8542 |
+
min-width: 0;
|
| 8543 |
+
overflow: hidden;
|
| 8544 |
+
color: var(--lp-ink);
|
| 8545 |
+
text-overflow: ellipsis;
|
| 8546 |
+
white-space: nowrap;
|
| 8547 |
+
}
|
| 8548 |
+
.shell-share-role { flex: 0 0 auto; color: var(--lp-muted); font-size: var(--lp-fs-2xs); }
|
| 8549 |
+
.shell-share-empty {
|
| 8550 |
+
padding: 10px;
|
| 8551 |
+
color: var(--lp-muted);
|
| 8552 |
+
font-size: var(--lp-fs-2xs);
|
| 8553 |
+
}
|
| 8554 |
+
.shell-share-select {
|
| 8555 |
+
flex: 0 0 auto;
|
| 8556 |
+
max-width: 190px;
|
| 8557 |
+
padding: 4px 6px;
|
| 8558 |
+
border: 1px solid var(--lp-line);
|
| 8559 |
+
border-radius: var(--lp-r-sm);
|
| 8560 |
+
background: var(--lp-surface);
|
| 8561 |
+
color: var(--lp-ink);
|
| 8562 |
+
font: inherit;
|
| 8563 |
+
font-size: var(--lp-fs-2xs);
|
| 8564 |
+
}
|
| 8565 |
+
.shell-share-select:disabled { opacity: 0.55; }
|
| 8566 |
+
.shell-share-revoke {
|
| 8567 |
+
flex: 0 0 auto;
|
| 8568 |
+
border: 0;
|
| 8569 |
+
border-radius: var(--lp-r-sm);
|
| 8570 |
+
padding: 4px 6px;
|
| 8571 |
+
background: transparent;
|
| 8572 |
+
color: var(--lp-red-deep);
|
| 8573 |
+
font: inherit;
|
| 8574 |
+
font-size: var(--lp-fs-2xs);
|
| 8575 |
+
cursor: pointer;
|
| 8576 |
+
}
|
| 8577 |
+
.shell-share-revoke:hover:not(:disabled) { background: var(--lp-red-tint); }
|
| 8578 |
+
.shell-share-revoke:disabled { opacity: 0.45; cursor: not-allowed; }
|
| 8579 |
+
.shell-share-add {
|
| 8580 |
+
display: flex;
|
| 8581 |
+
flex-wrap: wrap;
|
| 8582 |
+
align-items: center;
|
| 8583 |
+
gap: 8px;
|
| 8584 |
+
margin-top: 12px;
|
| 8585 |
+
}
|
| 8586 |
+
/* `.login-submit` is `display:block; width:100%` — right for a form's one button,
|
| 8587 |
+
wrong for the third control in a row. Same reset `.shell-hero-create` takes. */
|
| 8588 |
+
.shell-share-grant {
|
| 8589 |
+
display: inline-block;
|
| 8590 |
+
width: auto;
|
| 8591 |
+
height: auto;
|
| 8592 |
+
margin-top: 0;
|
| 8593 |
+
padding: 6px 12px;
|
| 8594 |
+
}
|
| 8595 |
+
.shell-share-blurb,
|
| 8596 |
+
.shell-share-note {
|
| 8597 |
+
flex: 1 1 100%;
|
| 8598 |
+
margin: 2px 0 0;
|
| 8599 |
+
color: var(--lp-muted);
|
| 8600 |
+
font-size: var(--lp-fs-2xs);
|
| 8601 |
+
line-height: 1.4;
|
| 8602 |
+
}
|
| 8603 |
+
.shell-share-note { margin-top: 12px; }
|
| 8604 |
+
|
| 8605 |
+
/* ── Item 25 (C-ALERT) — the Alerts row, its badge, and the inbox ───────────
|
| 8606 |
+
The row is a `<button>` wearing `.shell-nav-item`, so the UA's own button
|
| 8607 |
+
chrome has to go or it renders as a grey box in a rail of links (the wave-19
|
| 8608 |
+
R11 complaint, in the one place a nav row is not an anchor). */
|
| 8609 |
+
.shell-nav-alerts {
|
| 8610 |
+
width: 100%;
|
| 8611 |
+
border: 0;
|
| 8612 |
+
background: transparent;
|
| 8613 |
+
font: inherit;
|
| 8614 |
+
text-align: left;
|
| 8615 |
+
cursor: pointer;
|
| 8616 |
+
}
|
| 8617 |
+
.shell-nav-badge {
|
| 8618 |
+
flex: 0 0 auto;
|
| 8619 |
+
margin-left: auto;
|
| 8620 |
+
min-width: 18px;
|
| 8621 |
+
padding: 1px 5px;
|
| 8622 |
+
border-radius: var(--lp-r-pill);
|
| 8623 |
+
background: var(--lp-primary);
|
| 8624 |
+
color: var(--shell-on-solid, #ffffff);
|
| 8625 |
+
font-size: var(--lp-fs-4xs);
|
| 8626 |
+
font-weight: 600;
|
| 8627 |
+
font-variant-numeric: tabular-nums;
|
| 8628 |
+
text-align: center;
|
| 8629 |
+
}
|
| 8630 |
+
/* Collapsed, the label is gone and the badge would sit alone in a 56px strip
|
| 8631 |
+
pretending to be the icon. It rides the mark's top-right corner instead. */
|
| 8632 |
+
.shell-side.is-collapsed .shell-nav-badge {
|
| 8633 |
+
position: absolute;
|
| 8634 |
+
top: 2px;
|
| 8635 |
+
right: 6px;
|
| 8636 |
+
margin-left: 0;
|
| 8637 |
+
}
|
| 8638 |
+
.shell-side.is-collapsed .shell-nav-alerts { position: relative; }
|
| 8639 |
+
.alerts-pane {
|
| 8640 |
+
width: 520px;
|
| 8641 |
+
max-width: calc(100vw - 48px);
|
| 8642 |
+
max-height: 76vh;
|
| 8643 |
+
display: flex;
|
| 8644 |
+
flex-direction: column;
|
| 8645 |
+
background: var(--lp-surface);
|
| 8646 |
+
border: 1px solid var(--lp-line);
|
| 8647 |
+
border-radius: var(--lp-r-lg);
|
| 8648 |
+
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
|
| 8649 |
+
overflow: hidden;
|
| 8650 |
+
}
|
| 8651 |
+
.alerts-head {
|
| 8652 |
+
display: flex;
|
| 8653 |
+
align-items: center;
|
| 8654 |
+
gap: 10px;
|
| 8655 |
+
padding: 16px 20px 10px;
|
| 8656 |
+
}
|
| 8657 |
+
.alerts-head h2 {
|
| 8658 |
+
flex: 1 1 auto;
|
| 8659 |
+
display: flex;
|
| 8660 |
+
align-items: center;
|
| 8661 |
+
gap: 8px;
|
| 8662 |
+
margin: 0;
|
| 8663 |
+
font-size: var(--lp-fs-md);
|
| 8664 |
+
font-weight: 650;
|
| 8665 |
+
color: var(--lp-ink);
|
| 8666 |
+
}
|
| 8667 |
+
.alerts-bell {
|
| 8668 |
+
width: 18px;
|
| 8669 |
+
height: 18px;
|
| 8670 |
+
fill: none;
|
| 8671 |
+
stroke: currentColor;
|
| 8672 |
+
stroke-width: 1.3;
|
| 8673 |
+
stroke-linecap: round;
|
| 8674 |
+
stroke-linejoin: round;
|
| 8675 |
+
}
|
| 8676 |
+
.alerts-markall {
|
| 8677 |
+
flex: 0 0 auto;
|
| 8678 |
+
border: 1px solid var(--lp-line);
|
| 8679 |
+
border-radius: var(--lp-r-sm);
|
| 8680 |
+
padding: 4px 9px;
|
| 8681 |
+
background: var(--lp-surface);
|
| 8682 |
+
color: var(--lp-ink);
|
| 8683 |
+
font: inherit;
|
| 8684 |
+
font-size: var(--lp-fs-2xs);
|
| 8685 |
+
cursor: pointer;
|
| 8686 |
+
}
|
| 8687 |
+
.alerts-markall:hover:not(:disabled) { background: var(--lp-wash); }
|
| 8688 |
+
.alerts-markall:disabled { opacity: 0.45; cursor: not-allowed; }
|
| 8689 |
+
.alerts-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 20px; }
|
| 8690 |
+
.alerts-empty {
|
| 8691 |
+
margin: 8px 0 16px;
|
| 8692 |
+
color: var(--lp-muted);
|
| 8693 |
+
font-size: var(--lp-fs-2xs);
|
| 8694 |
+
line-height: 1.5;
|
| 8695 |
+
}
|
| 8696 |
+
.alerts-list { list-style: none; margin: 0; padding: 0; }
|
| 8697 |
+
.alerts-row {
|
| 8698 |
+
display: flex;
|
| 8699 |
+
align-items: center;
|
| 8700 |
+
gap: 8px;
|
| 8701 |
+
border-bottom: 1px solid var(--lp-line);
|
| 8702 |
+
}
|
| 8703 |
+
.alerts-row-main {
|
| 8704 |
+
flex: 1 1 auto;
|
| 8705 |
+
min-width: 0;
|
| 8706 |
+
display: flex;
|
| 8707 |
+
flex-direction: column;
|
| 8708 |
+
gap: 2px;
|
| 8709 |
+
border: 0;
|
| 8710 |
+
background: transparent;
|
| 8711 |
+
padding: 9px 4px;
|
| 8712 |
+
font: inherit;
|
| 8713 |
+
text-align: left;
|
| 8714 |
+
cursor: pointer;
|
| 8715 |
+
}
|
| 8716 |
+
.alerts-row-label {
|
| 8717 |
+
overflow: hidden;
|
| 8718 |
+
color: var(--lp-ink);
|
| 8719 |
+
font-size: var(--lp-fs-xs);
|
| 8720 |
+
text-overflow: ellipsis;
|
| 8721 |
+
white-space: nowrap;
|
| 8722 |
+
}
|
| 8723 |
+
/* The unread mark is WEIGHT plus a dot, never colour alone — the row already
|
| 8724 |
+
carries two colours' worth of meaning and a third would need explaining. */
|
| 8725 |
+
.alerts-row.is-unread .alerts-row-label { font-weight: 650; }
|
| 8726 |
+
.alerts-row.is-unread .alerts-row-label::before {
|
| 8727 |
+
content: "";
|
| 8728 |
+
display: inline-block;
|
| 8729 |
+
width: 6px;
|
| 8730 |
+
height: 6px;
|
| 8731 |
+
margin-right: 7px;
|
| 8732 |
+
border-radius: 50%;
|
| 8733 |
+
background: var(--lp-primary);
|
| 8734 |
+
vertical-align: middle;
|
| 8735 |
+
}
|
| 8736 |
+
.alerts-row-meta { color: var(--lp-muted); font-size: var(--lp-fs-3xs); }
|
| 8737 |
+
.alerts-row-toggle {
|
| 8738 |
+
flex: 0 0 auto;
|
| 8739 |
+
border: 0;
|
| 8740 |
+
border-radius: var(--lp-r-sm);
|
| 8741 |
+
padding: 4px 7px;
|
| 8742 |
+
background: transparent;
|
| 8743 |
+
color: var(--lp-muted);
|
| 8744 |
+
font: inherit;
|
| 8745 |
+
font-size: var(--lp-fs-3xs);
|
| 8746 |
+
cursor: pointer;
|
| 8747 |
+
}
|
| 8748 |
+
.alerts-row-toggle:hover { background: var(--lp-wash); color: var(--lp-ink); }
|
| 8749 |
+
.alerts-watching { margin: 18px 0 10px; }
|
| 8750 |
+
.alerts-watching h3 {
|
| 8751 |
+
margin: 0 0 6px;
|
| 8752 |
+
color: var(--lp-muted);
|
| 8753 |
+
font-size: var(--lp-fs-3xs);
|
| 8754 |
+
font-weight: 600;
|
| 8755 |
+
}
|
| 8756 |
+
.alerts-watch-row {
|
| 8757 |
+
display: flex;
|
| 8758 |
+
align-items: center;
|
| 8759 |
+
gap: 8px;
|
| 8760 |
+
padding: 6px 0;
|
| 8761 |
+
border-top: 1px solid var(--lp-line);
|
| 8762 |
+
font-size: var(--lp-fs-2xs);
|
| 8763 |
+
}
|
| 8764 |
+
.alerts-watch-label {
|
| 8765 |
+
flex: 1 1 auto;
|
| 8766 |
+
min-width: 0;
|
| 8767 |
+
overflow: hidden;
|
| 8768 |
+
color: var(--lp-ink);
|
| 8769 |
+
text-overflow: ellipsis;
|
| 8770 |
+
white-space: nowrap;
|
| 8771 |
+
}
|
| 8772 |
+
.alerts-watch-meta { flex: 0 0 auto; color: var(--lp-muted); font-size: var(--lp-fs-3xs); }
|
| 8773 |
+
.alerts-watch-run,
|
| 8774 |
+
.alerts-watch-del {
|
| 8775 |
+
flex: 0 0 auto;
|
| 8776 |
+
border: 0;
|
| 8777 |
+
border-radius: var(--lp-r-sm);
|
| 8778 |
+
padding: 3px 7px;
|
| 8779 |
+
background: transparent;
|
| 8780 |
+
color: var(--lp-muted);
|
| 8781 |
+
font: inherit;
|
| 8782 |
+
font-size: var(--lp-fs-3xs);
|
| 8783 |
+
cursor: pointer;
|
| 8784 |
+
}
|
| 8785 |
+
.alerts-watch-run:hover:not(:disabled) { background: var(--lp-wash); color: var(--lp-ink); }
|
| 8786 |
+
.alerts-watch-del { color: var(--lp-red-deep); }
|
| 8787 |
+
.alerts-watch-del:hover:not(:disabled) { background: var(--lp-red-tint); }
|
| 8788 |
+
.alerts-foot { padding: 10px 20px 16px; margin-top: 0; }
|
| 8789 |
+
|
| 8790 |
+
/* ── Item 7 (R9) — the collapsed rail expands from its own background ───────
|
| 8791 |
+
The strip's blank area is now a click target, so it says so. Interactive
|
| 8792 |
+
children keep their own cursor (the UA default for a link/button is already
|
| 8793 |
+
`pointer`), and `.shell-brand-btn` keeps the explicit one it had. */
|
| 8794 |
+
.shell-side.is-collapsed { cursor: pointer; }
|
| 8795 |
+
|
| 8796 |
+
/* ==== /wave20:S4 ==== */
|
| 8797 |
+
|
| 8798 |
+
/* ==== wave20:S3 grid =========================================================
|
| 8799 |
+
S3's region. Everything below belongs to the grid surface (customer-grid/,
|
| 8800 |
+
ui/, filter-kit/). Comment-anchored, never line-numbered: index.css is shared
|
| 8801 |
+
with S1/S4 this wave and is staged LAST by the integrator.
|
| 8802 |
+
============================================================================= */
|
| 8803 |
+
|
| 8804 |
+
/* Owner item 5 — THE ADD-FIELD "+" HUGS THE LAST COLUMN.
|
| 8805 |
+
glide lays its `rightElement` out as [ .dvn-stack (the columns) | .dvn-spacer |
|
| 8806 |
+
the element ], and its own rule `… .dvn-scroll-inner .dvn-spacer { flex-grow: 1 }`
|
| 8807 |
+
makes that spacer eat every pixel between the last column and the right edge of the
|
| 8808 |
+
scroller. Unpinning the wrapper (`rightElementProps.sticky = false`) is therefore only
|
| 8809 |
+
half the fix: without this the button lands back on the window edge, one flex rule later.
|
| 8810 |
+
|
| 8811 |
+
⚠ THE DOUBLED CLASS IS DELIBERATE. glide's selector is `.gdg-… .dvn-scroll-inner
|
| 8812 |
+
.dvn-spacer` — three classes, exactly the specificity of the obvious `.cg-grid-box
|
| 8813 |
+
.dvn-scroll-inner .dvn-spacer`, and at a tie the LATER rule wins. Which of the two
|
| 8814 |
+
stylesheets lands later is a bundler ordering detail (glide's CSS is imported from
|
| 8815 |
+
CustomerGrid.tsx, ours from main.tsx), and this project has already lost one rule to
|
| 8816 |
+
exactly that coin flip ([[loopable-nav-logo-toggle]]). `.cg-grid-box.cg-grid-box`
|
| 8817 |
+
repeats a class that is genuinely on the element, costs nothing, and wins on
|
| 8818 |
+
specificity rather than on order. */
|
| 8819 |
+
.cg-grid-box.cg-grid-box .dvn-scroll-inner .dvn-spacer { flex-grow: 0; }
|
| 8820 |
+
|
| 8821 |
+
/* Owner item 9 — the count each row of the remove picker would take out ("3 of 4"). Pushed to
|
| 8822 |
+
the trailing edge with `margin-left: auto` rather than a spacer element, and allowed to
|
| 8823 |
+
shrink last: the cohort NAME is what the reader is choosing between, so it keeps the
|
| 8824 |
+
ellipsis budget (`.cg-pick-row` above) and this never wraps. */
|
| 8825 |
+
.cg-pick-hint {
|
| 8826 |
+
margin-left: auto;
|
| 8827 |
+
padding-left: 10px;
|
| 8828 |
+
color: var(--lp-muted);
|
| 8829 |
+
font-size: var(--lp-fs-2xs);
|
| 8830 |
+
font-variant-numeric: tabular-nums;
|
| 8831 |
+
white-space: nowrap;
|
| 8832 |
+
flex: 0 0 auto;
|
| 8833 |
+
}
|
| 8834 |
+
|
| 8835 |
+
/* ==== /wave20:S3 ==== */
|
web/src/settings/AdminPane.tsx
CHANGED
|
@@ -1,568 +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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
*
|
| 68 |
-
*
|
| 69 |
-
*
|
| 70 |
-
*
|
| 71 |
-
*
|
| 72 |
-
*
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
if (
|
| 77 |
-
const
|
| 78 |
-
if (
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
if (
|
| 82 |
-
const
|
| 83 |
-
if (
|
| 84 |
-
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
const [
|
| 113 |
-
|
| 114 |
-
const [
|
| 115 |
-
|
| 116 |
-
const [
|
| 117 |
-
|
| 118 |
-
const [
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
/>
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
<
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
<td>
|
| 234 |
-
<span className="padmin-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
}
|
| 265 |
-
/>
|
| 266 |
-
<DrillCell
|
| 267 |
-
n={t.
|
| 268 |
-
onOpen={() => openDrill("
|
| 269 |
-
why={t.errors?.[0]}
|
| 270 |
-
title={
|
| 271 |
-
t.
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 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 |
-
|
| 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 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 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 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 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 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
<
|
| 494 |
-
|
| 495 |
-
|
| 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 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/platformAdminApi.ts
CHANGED
|
@@ -1,224 +1,263 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// settings / platformAdminApi.ts — the Loopable admin plane's client half
|
| 3 |
-
// (wave 19, owner item 13 / R3+R4, contract C2).
|
| 4 |
-
//
|
| 5 |
-
// ⛔ THE CLIENT HIDES; THE SERVER FORBIDS. Every route here is walled by
|
| 6 |
-
// `core.platform_admin.is_platform_admin` — a double lock (the record flag AND
|
| 7 |
-
// the `loopable` tenant), fail-closed, proven by `verify_api.py` enumerating the
|
| 8 |
-
// router and having a tenant admin try every path. The `platformAdmin` flag on
|
| 9 |
-
// `GET /settings` exists so the rail does not paint a door that would 403; it is
|
| 10 |
-
// never the permission ([[aios-permissioning]]).
|
| 11 |
-
//
|
| 12 |
-
// SELF-CONTAINED BY CONTRACT (C2). This module and `AdminPane.tsx` are the only
|
| 13 |
-
// two files the admin plane owns on the client, and they import nothing from the
|
| 14 |
-
// rest of `settings/` — the pane fetches its own data, so mounting it is one line
|
| 15 |
-
// in the rail and one line in the pane switch.
|
| 16 |
-
//
|
| 17 |
-
// ⚠ NULL IS A REAL VALUE HERE, AND IT IS NOT ZERO. Any per-tenant count can come
|
| 18 |
-
// back `null`, meaning "that subsystem could not be read for this customer" — a
|
| 19 |
-
// suspended tenant record, a locked keychain, an HF repo that did not answer.
|
| 20 |
-
// Rendering it as 0 would tell an operator a customer has no databases when the
|
| 21 |
-
// truth is that we did not look. The types say `number | null` for exactly that
|
| 22 |
-
// reason and the pane renders the difference.
|
| 23 |
-
// ---------------------------------------------------------------------------
|
| 24 |
-
|
| 25 |
-
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 26 |
-
|
| 27 |
-
export type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
|
| 28 |
-
|
| 29 |
-
async function call<T>(path: string): Promise<ApiResult<T>> {
|
| 30 |
-
try {
|
| 31 |
-
const res = await fetch(`${API_V1}/platform-admin${path}`, { credentials: CREDENTIALS });
|
| 32 |
-
let body: unknown = null;
|
| 33 |
-
try {
|
| 34 |
-
body = await res.json();
|
| 35 |
-
} catch {
|
| 36 |
-
/* an unreadable body is handled below, never thrown at the pane */
|
| 37 |
-
}
|
| 38 |
-
if (!res.ok) {
|
| 39 |
-
const err = (body as { error?: { message?: string } } | null)?.error;
|
| 40 |
-
const message =
|
| 41 |
-
res.status >= 500
|
| 42 |
-
? "Something went wrong on our side. Try again in a moment."
|
| 43 |
-
: err?.message ||
|
| 44 |
-
(res.status === 403
|
| 45 |
-
? "This surface is not available to your account."
|
| 46 |
-
: "That could not be loaded.");
|
| 47 |
-
return { ok: false, status: res.status, message };
|
| 48 |
-
}
|
| 49 |
-
return { ok: true, data: body as T };
|
| 50 |
-
} catch {
|
| 51 |
-
return { ok: false, status: 0, message: "The server could not be reached." };
|
| 52 |
-
}
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
/** One customer. `error` / `errors` carry the honest reason a count is null. */
|
| 56 |
-
export interface TenantRow {
|
| 57 |
-
slug: string;
|
| 58 |
-
name: string;
|
| 59 |
-
/** `record` = provisioned into the control-plane bucket; `compiled` = tenant #0. */
|
| 60 |
-
source: string;
|
| 61 |
-
status: string;
|
| 62 |
-
domains: string[];
|
| 63 |
-
modules: string[] | "all";
|
| 64 |
-
/** R2: a dedicated dataset repo, or the shared repo. The isolation shape. */
|
| 65 |
-
storeRepo: string;
|
| 66 |
-
storePrefix: string;
|
| 67 |
-
error: string;
|
| 68 |
-
users: number;
|
| 69 |
-
admins: number;
|
| 70 |
-
databases: number | null;
|
| 71 |
-
rows: number | null;
|
| 72 |
-
connectors: number | null;
|
| 73 |
-
connectorsPaused?: number | null;
|
| 74 |
-
automations: number | null;
|
| 75 |
-
automationsEnabled?: number | null;
|
| 76 |
-
keychainLocked?: boolean | null;
|
| 77 |
-
errors?: string[];
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
export interface Overview {
|
| 81 |
-
tenants: TenantRow[];
|
| 82 |
-
totals: {
|
| 83 |
-
tenants: number;
|
| 84 |
-
users: number;
|
| 85 |
-
orphanUsers: number;
|
| 86 |
-
databases: number;
|
| 87 |
-
rows: number;
|
| 88 |
-
automations: number;
|
| 89 |
-
unknownTenants: number;
|
| 90 |
-
};
|
| 91 |
-
storeAvailable: boolean;
|
| 92 |
-
generatedAt: string;
|
| 93 |
-
tookMs: number;
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
export interface PlatformUser {
|
| 97 |
-
username: string;
|
| 98 |
-
name: string;
|
| 99 |
-
email: string;
|
| 100 |
-
tenant: string;
|
| 101 |
-
role: string;
|
| 102 |
-
active: boolean;
|
| 103 |
-
/** R4 — absent means never, and the pane says "never" rather than inventing a date. */
|
| 104 |
-
lastLogin: string;
|
| 105 |
-
lastActive: string;
|
| 106 |
-
platformAdmin: boolean;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
export interface DatabaseRow {
|
| 110 |
-
tenant: string;
|
| 111 |
-
key: string;
|
| 112 |
-
label: string;
|
| 113 |
-
source: string;
|
| 114 |
-
createdBy: string;
|
| 115 |
-
created: string;
|
| 116 |
-
fields: number;
|
| 117 |
-
rowCount: number;
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
export interface ConnectorRow {
|
| 121 |
-
tenant: string;
|
| 122 |
-
key: string;
|
| 123 |
-
label: string;
|
| 124 |
-
type: string;
|
| 125 |
-
source: string;
|
| 126 |
-
active: boolean;
|
| 127 |
-
paused: boolean;
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
export interface AutomationRow {
|
| 131 |
-
tenant: string;
|
| 132 |
-
id: string;
|
| 133 |
-
name: string;
|
| 134 |
-
kind: string;
|
| 135 |
-
enabled: boolean;
|
| 136 |
-
cron: string;
|
| 137 |
-
/** null = a cadence the server would not parse; shown as "custom", never as a number. */
|
| 138 |
-
runsPerDay: number | null;
|
| 139 |
-
state: string;
|
| 140 |
-
lastRunAt: string;
|
| 141 |
-
lastSummary: string;
|
| 142 |
-
runsRetained: number;
|
| 143 |
-
failedRetained: number;
|
| 144 |
-
createdBy: string;
|
| 145 |
-
created: string;
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
export interface FleetCost {
|
| 149 |
-
cadence: string;
|
| 150 |
-
invocationsPerMonth: number;
|
| 151 |
-
freeRequestsPct: number;
|
| 152 |
-
freeSchedulerPct: number;
|
| 153 |
-
lambdaMb: number;
|
| 154 |
-
usd: number;
|
| 155 |
-
fleetRunsPerDay: number;
|
| 156 |
-
unknownCadence: number;
|
| 157 |
-
basis: string;
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
export interface AwsReport {
|
| 161 |
-
available: boolean;
|
| 162 |
-
text: string;
|
| 163 |
-
note: string;
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
export function getOverview(): Promise<ApiResult<Overview>> {
|
| 167 |
-
return call<Overview>("/overview");
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
/** `tenant` narrows every drill; omitted, they answer for the whole platform. */
|
| 171 |
-
const q = (tenant?: string) => (tenant ? `?tenant=${encodeURIComponent(tenant)}` : "");
|
| 172 |
-
|
| 173 |
-
export function getUsers(
|
| 174 |
-
tenant?: string
|
| 175 |
-
): Promise<ApiResult<{ users: PlatformUser[]; count: number; stampsNote: string }>> {
|
| 176 |
-
return call(`/users${q(tenant)}`);
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
export function getDatabases(
|
| 180 |
-
tenant?: string
|
| 181 |
-
): Promise<
|
| 182 |
-
ApiResult<{
|
| 183 |
-
databases: DatabaseRow[];
|
| 184 |
-
count: number;
|
| 185 |
-
rows: number;
|
| 186 |
-
errors: Record<string, string>;
|
| 187 |
-
}>
|
| 188 |
-
> {
|
| 189 |
-
return call(`/databases${q(tenant)}`);
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
export function getConnectors(
|
| 193 |
-
tenant?: string
|
| 194 |
-
): Promise<
|
| 195 |
-
ApiResult<{
|
| 196 |
-
connectors: ConnectorRow[];
|
| 197 |
-
count: number;
|
| 198 |
-
keychainLocked: Record<string, boolean>;
|
| 199 |
-
errors: Record<string, string>;
|
| 200 |
-
note: string;
|
| 201 |
-
}>
|
| 202 |
-
> {
|
| 203 |
-
return call(`/connectors${q(tenant)}`);
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
export function getAutomations(
|
| 207 |
-
tenant?: string
|
| 208 |
-
): Promise<
|
| 209 |
-
ApiResult<{
|
| 210 |
-
automations: AutomationRow[];
|
| 211 |
-
count: number;
|
| 212 |
-
enabled: number;
|
| 213 |
-
historyRetained: number;
|
| 214 |
-
cost: FleetCost;
|
| 215 |
-
errors: Record<string, string>;
|
| 216 |
-
tickEnabled: boolean;
|
| 217 |
-
}>
|
| 218 |
-
> {
|
| 219 |
-
return call(`/automations${q(tenant)}`);
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
export function getAws(days = 7): Promise<ApiResult<{ days: number; report: AwsReport }>> {
|
| 223 |
-
return call(`/aws?days=${encodeURIComponent(String(days))}`);
|
| 224 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// settings / platformAdminApi.ts — the Loopable admin plane's client half
|
| 3 |
+
// (wave 19, owner item 13 / R3+R4, contract C2).
|
| 4 |
+
//
|
| 5 |
+
// ⛔ THE CLIENT HIDES; THE SERVER FORBIDS. Every route here is walled by
|
| 6 |
+
// `core.platform_admin.is_platform_admin` — a double lock (the record flag AND
|
| 7 |
+
// the `loopable` tenant), fail-closed, proven by `verify_api.py` enumerating the
|
| 8 |
+
// router and having a tenant admin try every path. The `platformAdmin` flag on
|
| 9 |
+
// `GET /settings` exists so the rail does not paint a door that would 403; it is
|
| 10 |
+
// never the permission ([[aios-permissioning]]).
|
| 11 |
+
//
|
| 12 |
+
// SELF-CONTAINED BY CONTRACT (C2). This module and `AdminPane.tsx` are the only
|
| 13 |
+
// two files the admin plane owns on the client, and they import nothing from the
|
| 14 |
+
// rest of `settings/` — the pane fetches its own data, so mounting it is one line
|
| 15 |
+
// in the rail and one line in the pane switch.
|
| 16 |
+
//
|
| 17 |
+
// ⚠ NULL IS A REAL VALUE HERE, AND IT IS NOT ZERO. Any per-tenant count can come
|
| 18 |
+
// back `null`, meaning "that subsystem could not be read for this customer" — a
|
| 19 |
+
// suspended tenant record, a locked keychain, an HF repo that did not answer.
|
| 20 |
+
// Rendering it as 0 would tell an operator a customer has no databases when the
|
| 21 |
+
// truth is that we did not look. The types say `number | null` for exactly that
|
| 22 |
+
// reason and the pane renders the difference.
|
| 23 |
+
// ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 26 |
+
|
| 27 |
+
export type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
|
| 28 |
+
|
| 29 |
+
async function call<T>(path: string): Promise<ApiResult<T>> {
|
| 30 |
+
try {
|
| 31 |
+
const res = await fetch(`${API_V1}/platform-admin${path}`, { credentials: CREDENTIALS });
|
| 32 |
+
let body: unknown = null;
|
| 33 |
+
try {
|
| 34 |
+
body = await res.json();
|
| 35 |
+
} catch {
|
| 36 |
+
/* an unreadable body is handled below, never thrown at the pane */
|
| 37 |
+
}
|
| 38 |
+
if (!res.ok) {
|
| 39 |
+
const err = (body as { error?: { message?: string } } | null)?.error;
|
| 40 |
+
const message =
|
| 41 |
+
res.status >= 500
|
| 42 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 43 |
+
: err?.message ||
|
| 44 |
+
(res.status === 403
|
| 45 |
+
? "This surface is not available to your account."
|
| 46 |
+
: "That could not be loaded.");
|
| 47 |
+
return { ok: false, status: res.status, message };
|
| 48 |
+
}
|
| 49 |
+
return { ok: true, data: body as T };
|
| 50 |
+
} catch {
|
| 51 |
+
return { ok: false, status: 0, message: "The server could not be reached." };
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** One customer. `error` / `errors` carry the honest reason a count is null. */
|
| 56 |
+
export interface TenantRow {
|
| 57 |
+
slug: string;
|
| 58 |
+
name: string;
|
| 59 |
+
/** `record` = provisioned into the control-plane bucket; `compiled` = tenant #0. */
|
| 60 |
+
source: string;
|
| 61 |
+
status: string;
|
| 62 |
+
domains: string[];
|
| 63 |
+
modules: string[] | "all";
|
| 64 |
+
/** R2: a dedicated dataset repo, or the shared repo. The isolation shape. */
|
| 65 |
+
storeRepo: string;
|
| 66 |
+
storePrefix: string;
|
| 67 |
+
error: string;
|
| 68 |
+
users: number;
|
| 69 |
+
admins: number;
|
| 70 |
+
databases: number | null;
|
| 71 |
+
rows: number | null;
|
| 72 |
+
connectors: number | null;
|
| 73 |
+
connectorsPaused?: number | null;
|
| 74 |
+
automations: number | null;
|
| 75 |
+
automationsEnabled?: number | null;
|
| 76 |
+
keychainLocked?: boolean | null;
|
| 77 |
+
errors?: string[];
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export interface Overview {
|
| 81 |
+
tenants: TenantRow[];
|
| 82 |
+
totals: {
|
| 83 |
+
tenants: number;
|
| 84 |
+
users: number;
|
| 85 |
+
orphanUsers: number;
|
| 86 |
+
databases: number;
|
| 87 |
+
rows: number;
|
| 88 |
+
automations: number;
|
| 89 |
+
unknownTenants: number;
|
| 90 |
+
};
|
| 91 |
+
storeAvailable: boolean;
|
| 92 |
+
generatedAt: string;
|
| 93 |
+
tookMs: number;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export interface PlatformUser {
|
| 97 |
+
username: string;
|
| 98 |
+
name: string;
|
| 99 |
+
email: string;
|
| 100 |
+
tenant: string;
|
| 101 |
+
role: string;
|
| 102 |
+
active: boolean;
|
| 103 |
+
/** R4 — absent means never, and the pane says "never" rather than inventing a date. */
|
| 104 |
+
lastLogin: string;
|
| 105 |
+
lastActive: string;
|
| 106 |
+
platformAdmin: boolean;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export interface DatabaseRow {
|
| 110 |
+
tenant: string;
|
| 111 |
+
key: string;
|
| 112 |
+
label: string;
|
| 113 |
+
source: string;
|
| 114 |
+
createdBy: string;
|
| 115 |
+
created: string;
|
| 116 |
+
fields: number;
|
| 117 |
+
rowCount: number;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
export interface ConnectorRow {
|
| 121 |
+
tenant: string;
|
| 122 |
+
key: string;
|
| 123 |
+
label: string;
|
| 124 |
+
type: string;
|
| 125 |
+
source: string;
|
| 126 |
+
active: boolean;
|
| 127 |
+
paused: boolean;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export interface AutomationRow {
|
| 131 |
+
tenant: string;
|
| 132 |
+
id: string;
|
| 133 |
+
name: string;
|
| 134 |
+
kind: string;
|
| 135 |
+
enabled: boolean;
|
| 136 |
+
cron: string;
|
| 137 |
+
/** null = a cadence the server would not parse; shown as "custom", never as a number. */
|
| 138 |
+
runsPerDay: number | null;
|
| 139 |
+
state: string;
|
| 140 |
+
lastRunAt: string;
|
| 141 |
+
lastSummary: string;
|
| 142 |
+
runsRetained: number;
|
| 143 |
+
failedRetained: number;
|
| 144 |
+
createdBy: string;
|
| 145 |
+
created: string;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export interface FleetCost {
|
| 149 |
+
cadence: string;
|
| 150 |
+
invocationsPerMonth: number;
|
| 151 |
+
freeRequestsPct: number;
|
| 152 |
+
freeSchedulerPct: number;
|
| 153 |
+
lambdaMb: number;
|
| 154 |
+
usd: number;
|
| 155 |
+
fleetRunsPerDay: number;
|
| 156 |
+
unknownCadence: number;
|
| 157 |
+
basis: string;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export interface AwsReport {
|
| 161 |
+
available: boolean;
|
| 162 |
+
text: string;
|
| 163 |
+
note: string;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export function getOverview(): Promise<ApiResult<Overview>> {
|
| 167 |
+
return call<Overview>("/overview");
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/** `tenant` narrows every drill; omitted, they answer for the whole platform. */
|
| 171 |
+
const q = (tenant?: string) => (tenant ? `?tenant=${encodeURIComponent(tenant)}` : "");
|
| 172 |
+
|
| 173 |
+
export function getUsers(
|
| 174 |
+
tenant?: string
|
| 175 |
+
): Promise<ApiResult<{ users: PlatformUser[]; count: number; stampsNote: string }>> {
|
| 176 |
+
return call(`/users${q(tenant)}`);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
export function getDatabases(
|
| 180 |
+
tenant?: string
|
| 181 |
+
): Promise<
|
| 182 |
+
ApiResult<{
|
| 183 |
+
databases: DatabaseRow[];
|
| 184 |
+
count: number;
|
| 185 |
+
rows: number;
|
| 186 |
+
errors: Record<string, string>;
|
| 187 |
+
}>
|
| 188 |
+
> {
|
| 189 |
+
return call(`/databases${q(tenant)}`);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export function getConnectors(
|
| 193 |
+
tenant?: string
|
| 194 |
+
): Promise<
|
| 195 |
+
ApiResult<{
|
| 196 |
+
connectors: ConnectorRow[];
|
| 197 |
+
count: number;
|
| 198 |
+
keychainLocked: Record<string, boolean>;
|
| 199 |
+
errors: Record<string, string>;
|
| 200 |
+
note: string;
|
| 201 |
+
}>
|
| 202 |
+
> {
|
| 203 |
+
return call(`/connectors${q(tenant)}`);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
export function getAutomations(
|
| 207 |
+
tenant?: string
|
| 208 |
+
): Promise<
|
| 209 |
+
ApiResult<{
|
| 210 |
+
automations: AutomationRow[];
|
| 211 |
+
count: number;
|
| 212 |
+
enabled: number;
|
| 213 |
+
historyRetained: number;
|
| 214 |
+
cost: FleetCost;
|
| 215 |
+
errors: Record<string, string>;
|
| 216 |
+
tickEnabled: boolean;
|
| 217 |
+
}>
|
| 218 |
+
> {
|
| 219 |
+
return call(`/automations${q(tenant)}`);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
export function getAws(days = 7): Promise<ApiResult<{ days: number; report: AwsReport }>> {
|
| 223 |
+
return call(`/aws?days=${encodeURIComponent(String(days))}`);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// ── Wave 20 (owner item 12 / R6): RELEASES ──────────────────────────────────
|
| 227 |
+
// Read-only by ruling. The operator SEES what is running where and what could be
|
| 228 |
+
// gone back to; promoting stays `deploy_web.py --promote=vN`, so no web session
|
| 229 |
+
// can move production. `promote` carries that command in the payload because the
|
| 230 |
+
// person reading this panel is exactly the person who needs it.
|
| 231 |
+
|
| 232 |
+
/** One environment. `version` is what the SPACE says about itself (its own
|
| 233 |
+
* `VERSION` file) — never inferred from a timestamp, because `last_modified`
|
| 234 |
+
* moves when a SECRET is pushed and would report a deploy that never happened. */
|
| 235 |
+
export interface ReleaseEnv {
|
| 236 |
+
env: string;
|
| 237 |
+
space: string;
|
| 238 |
+
/** null + a `note` = we could not read it. NOT "nothing is deployed". */
|
| 239 |
+
version: string | null;
|
| 240 |
+
note: string | null;
|
| 241 |
+
/** HF runtime stage (`RUNNING`, `BUILDING`, …), or null when unreadable. */
|
| 242 |
+
stage: string | null;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
export interface ReleaseTag {
|
| 246 |
+
version: string;
|
| 247 |
+
sha: string;
|
| 248 |
+
date: string;
|
| 249 |
+
subject: string;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
export interface ReleasesPayload {
|
| 253 |
+
/** The version THIS container is running (`AIOS_VERSION`), for the "you are here" case. */
|
| 254 |
+
here: string;
|
| 255 |
+
environments: ReleaseEnv[];
|
| 256 |
+
releases: ReleaseTag[];
|
| 257 |
+
/** The CLI that moves a version. In the payload because the panel is read-only by ruling. */
|
| 258 |
+
promote: string;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
export function getReleases(): Promise<ApiResult<ReleasesPayload>> {
|
| 262 |
+
return call("/releases");
|
| 263 |
+
}
|
web/src/shell/NavExtras.tsx
CHANGED
|
@@ -180,6 +180,7 @@ export function RowMenu({
|
|
| 180 |
icon,
|
| 181 |
onIcon,
|
| 182 |
onIconClear,
|
|
|
|
| 183 |
}: {
|
| 184 |
entryLabel: string;
|
| 185 |
canSchema: boolean;
|
|
@@ -199,6 +200,9 @@ export function RowMenu({
|
|
| 199 |
icon?: FolderIcon;
|
| 200 |
onIcon?: (icon: FolderIcon) => void;
|
| 201 |
onIconClear?: () => void;
|
|
|
|
|
|
|
|
|
|
| 202 |
}) {
|
| 203 |
// Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
|
| 204 |
// Wave 19 (R8/C1) adds Rename and Change icon — the first two things a
|
|
@@ -210,7 +214,7 @@ export function RowMenu({
|
|
| 210 |
const [name, setName] = useState(entryLabel);
|
| 211 |
const renameOn = !!canRename && !!onRename;
|
| 212 |
const iconOn = !!canIcon && !!onIcon;
|
| 213 |
-
if (!canSchema && !renameOn && !iconOn) return null;
|
| 214 |
const submitRename = () => {
|
| 215 |
const clean = name.trim().replace(/\s+/g, " ");
|
| 216 |
if (clean && clean !== entryLabel) onRename?.(clean);
|
|
@@ -312,6 +316,22 @@ export function RowMenu({
|
|
| 312 |
View schema
|
| 313 |
</button>
|
| 314 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
</>
|
| 316 |
)}
|
| 317 |
</MenuShell>
|
|
|
|
| 180 |
icon,
|
| 181 |
onIcon,
|
| 182 |
onIconClear,
|
| 183 |
+
onShare,
|
| 184 |
}: {
|
| 185 |
entryLabel: string;
|
| 186 |
canSchema: boolean;
|
|
|
|
| 200 |
icon?: FolderIcon;
|
| 201 |
onIcon?: (icon: FolderIcon) => void;
|
| 202 |
onIconClear?: () => void;
|
| 203 |
+
/** WAVE 20 item 18 (C-SHARE) — open the access editor for THIS database.
|
| 204 |
+
* Absent = the row does not offer sharing (a group head has nothing to share). */
|
| 205 |
+
onShare?: () => void;
|
| 206 |
}) {
|
| 207 |
// Wave 14 C-NAVFOLD (ruling R10): the three-dots kept "View schema" ONLY.
|
| 208 |
// Wave 19 (R8/C1) adds Rename and Change icon — the first two things a
|
|
|
|
| 214 |
const [name, setName] = useState(entryLabel);
|
| 215 |
const renameOn = !!canRename && !!onRename;
|
| 216 |
const iconOn = !!canIcon && !!onIcon;
|
| 217 |
+
if (!canSchema && !renameOn && !iconOn && !onShare) return null;
|
| 218 |
const submitRename = () => {
|
| 219 |
const clean = name.trim().replace(/\s+/g, " ");
|
| 220 |
if (clean && clean !== entryLabel) onRename?.(clean);
|
|
|
|
| 316 |
View schema
|
| 317 |
</button>
|
| 318 |
) : null}
|
| 319 |
+
{/* WAVE 20 item 18 (R10, C-SHARE) — a DATABASE shares with the same two
|
| 320 |
+
roles a view does. Gated on `onShare`, which the Shell passes only
|
| 321 |
+
for a real database row, so a family head or a hand-off link never
|
| 322 |
+
offers to share something that has no id on the server. */}
|
| 323 |
+
{onShare ? (
|
| 324 |
+
<button
|
| 325 |
+
type="button"
|
| 326 |
+
className="shell-navmenu-item"
|
| 327 |
+
onClick={() => {
|
| 328 |
+
close();
|
| 329 |
+
onShare();
|
| 330 |
+
}}
|
| 331 |
+
>
|
| 332 |
+
Share…
|
| 333 |
+
</button>
|
| 334 |
+
) : null}
|
| 335 |
</>
|
| 336 |
)}
|
| 337 |
</MenuShell>
|
web/src/shell/ShareDialog.tsx
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// shell/ShareDialog.tsx — WAVE 20 items 18 / 23 / 26 (R10, contract C-SHARE):
|
| 3 |
+
// ONE dialog that shares a view, a folder or a database, and edits who already
|
| 4 |
+
// has it.
|
| 5 |
+
//
|
| 6 |
+
// ONE dialog for three kinds is the ruling, not a convenience: R10 says folders
|
| 7 |
+
// and databases share "with the SAME two-role vocabulary views use". Three
|
| 8 |
+
// dialogs would drift into three vocabularies within a wave.
|
| 9 |
+
//
|
| 10 |
+
// It renders over the ALREADY-LOADED state and decides nothing itself — every
|
| 11 |
+
// rule (what a junk entry means, who may administer, what the PUT carries) is in
|
| 12 |
+
// `shareModel.ts`, where the access gate can run it.
|
| 13 |
+
// ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
import { useCallback, useEffect, useState } from "react";
|
| 16 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 17 |
+
import {
|
| 18 |
+
SHARE_ROLES,
|
| 19 |
+
addablePeople,
|
| 20 |
+
parseShare,
|
| 21 |
+
sharePutBody,
|
| 22 |
+
shareSummary,
|
| 23 |
+
withEntry,
|
| 24 |
+
withoutEntry,
|
| 25 |
+
} from "./shareModel";
|
| 26 |
+
import type { ShareEntry, ShareKind, ShareRole, ShareState } from "./shareModel";
|
| 27 |
+
|
| 28 |
+
const KIND_WORD: Record<ShareKind, string> = {
|
| 29 |
+
view: "view",
|
| 30 |
+
folder: "folder",
|
| 31 |
+
database: "database",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
/** What each role MEANS on each kind, in the reader's own terms. A role picker
|
| 35 |
+
* whose options are two nouns makes the reader guess; these are the sentences
|
| 36 |
+
* the view rail already uses ("Anyone who can see this table can change it"),
|
| 37 |
+
* extended to the two new kinds rather than re-invented for them. */
|
| 38 |
+
const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
|
| 39 |
+
view: {
|
| 40 |
+
view: "Can open this view. Cannot rename, refilter or delete it.",
|
| 41 |
+
edit: "Can change this view's filters, sorts and columns.",
|
| 42 |
+
},
|
| 43 |
+
folder: {
|
| 44 |
+
view: "Can open the folder and the views inside it.",
|
| 45 |
+
edit: "Can rename the folder and move views in and out of it.",
|
| 46 |
+
},
|
| 47 |
+
database: {
|
| 48 |
+
view: "Can open this database and read its records.",
|
| 49 |
+
edit: "Can add, edit and delete its records.",
|
| 50 |
+
},
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export default function ShareDialog({
|
| 54 |
+
kind,
|
| 55 |
+
id,
|
| 56 |
+
label,
|
| 57 |
+
me,
|
| 58 |
+
onClose,
|
| 59 |
+
onToast,
|
| 60 |
+
}: {
|
| 61 |
+
kind: ShareKind;
|
| 62 |
+
id: string;
|
| 63 |
+
label: string;
|
| 64 |
+
/** The signed-in account's username, so the reader recognises themselves in the
|
| 65 |
+
* list. `people` is deliberately the OTHER accounts (it is the add-picker's
|
| 66 |
+
* source), so without this the owner row prints a raw login where every other
|
| 67 |
+
* row prints a name. */
|
| 68 |
+
me: string;
|
| 69 |
+
onClose: () => void;
|
| 70 |
+
onToast: (message: string) => void;
|
| 71 |
+
}) {
|
| 72 |
+
const [state, setState] = useState<ShareState | null>(null);
|
| 73 |
+
const [entries, setEntries] = useState<ShareEntry[]>([]);
|
| 74 |
+
const [error, setError] = useState("");
|
| 75 |
+
const [busy, setBusy] = useState(false);
|
| 76 |
+
const [pick, setPick] = useState("");
|
| 77 |
+
const [pickRole, setPickRole] = useState<ShareRole>("view");
|
| 78 |
+
|
| 79 |
+
const path = `${API_V1}/share/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`;
|
| 80 |
+
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
let dead = false;
|
| 83 |
+
setError("");
|
| 84 |
+
void (async () => {
|
| 85 |
+
try {
|
| 86 |
+
const res = await fetch(path, { credentials: CREDENTIALS });
|
| 87 |
+
if (!res.ok) {
|
| 88 |
+
// 4xx text is policy the reader needs; a 5xx's internals are not theirs.
|
| 89 |
+
if (!dead) setError(res.status >= 500
|
| 90 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 91 |
+
: `The server answered ${res.status}.`);
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 95 |
+
if (dead) return;
|
| 96 |
+
const parsed = parseShare(body);
|
| 97 |
+
setState(parsed);
|
| 98 |
+
setEntries(parsed.entries);
|
| 99 |
+
} catch {
|
| 100 |
+
if (!dead) setError("Cannot reach the server.");
|
| 101 |
+
}
|
| 102 |
+
})();
|
| 103 |
+
return () => {
|
| 104 |
+
dead = true;
|
| 105 |
+
};
|
| 106 |
+
}, [path]);
|
| 107 |
+
|
| 108 |
+
// ⛔ ESCAPE CLOSES A MODAL, or its scrim becomes a trap — the wave-18 lesson this
|
| 109 |
+
// shell already carries at its other two dialogs.
|
| 110 |
+
useEffect(() => {
|
| 111 |
+
const onKey = (e: KeyboardEvent) => {
|
| 112 |
+
if (e.key === "Escape" && !busy) onClose();
|
| 113 |
+
};
|
| 114 |
+
window.addEventListener("keydown", onKey);
|
| 115 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 116 |
+
}, [busy, onClose]);
|
| 117 |
+
|
| 118 |
+
const save = useCallback(
|
| 119 |
+
async (next: ShareEntry[]) => {
|
| 120 |
+
setBusy(true);
|
| 121 |
+
setError("");
|
| 122 |
+
try {
|
| 123 |
+
const res = await fetch(path, {
|
| 124 |
+
method: "PUT",
|
| 125 |
+
credentials: CREDENTIALS,
|
| 126 |
+
headers: { "Content-Type": "application/json" },
|
| 127 |
+
// The WHOLE list, every time: the PUT replaces, so a body assembled from
|
| 128 |
+
// a delta would revoke everyone it failed to mention.
|
| 129 |
+
body: JSON.stringify(sharePutBody(next)),
|
| 130 |
+
});
|
| 131 |
+
if (!res.ok) {
|
| 132 |
+
const body = (await res.json().catch(() => null)) as
|
| 133 |
+
| { error?: { message?: string } }
|
| 134 |
+
| null;
|
| 135 |
+
setError(
|
| 136 |
+
res.status >= 500
|
| 137 |
+
? "Something went wrong on our side. Try again in a moment."
|
| 138 |
+
: body?.error?.message || `The server answered ${res.status}.`
|
| 139 |
+
);
|
| 140 |
+
return false;
|
| 141 |
+
}
|
| 142 |
+
const body = (await res.json().catch(() => null)) as unknown;
|
| 143 |
+
// Re-read the SERVER's copy rather than trusting the draft: `_clean_entries`
|
| 144 |
+
// drops what it will not store, and an editor that kept showing a row the
|
| 145 |
+
// store rejected would be the "shared, silently inert" failure this feature
|
| 146 |
+
// exists to avoid.
|
| 147 |
+
// ⚠ Only `.entries` is consumed here — the PUT answers with the stored record
|
| 148 |
+
// (`{owner, entries}`) and says nothing about this session's standing, so the
|
| 149 |
+
// literal below feeds the parser's required field and is never read back. The
|
| 150 |
+
// editor's `mayAdminister` stays the one the GET established; a save cannot
|
| 151 |
+
// promote anybody, and this line must never be the reason it looks like it can.
|
| 152 |
+
const saved = parseShare({ ...(body as object), mayAdminister: true });
|
| 153 |
+
setEntries(saved.entries);
|
| 154 |
+
return true;
|
| 155 |
+
} catch {
|
| 156 |
+
setError("Cannot reach the server.");
|
| 157 |
+
return false;
|
| 158 |
+
} finally {
|
| 159 |
+
setBusy(false);
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
[path]
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
const mayAdminister = !!state?.mayAdminister;
|
| 166 |
+
const options = state ? addablePeople(state.people, entries) : [];
|
| 167 |
+
const nameOf = (user: string) =>
|
| 168 |
+
user === "*"
|
| 169 |
+
? "Everyone"
|
| 170 |
+
: user && user === me.trim().toLowerCase()
|
| 171 |
+
? "You"
|
| 172 |
+
: state?.people.find((p) => p.user === user)?.name ?? user;
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}>
|
| 176 |
+
<div
|
| 177 |
+
className="shell-newdb shell-share"
|
| 178 |
+
role="dialog"
|
| 179 |
+
aria-label={`Share ${label}`}
|
| 180 |
+
onClick={(e) => e.stopPropagation()}
|
| 181 |
+
>
|
| 182 |
+
<h2>Share {KIND_WORD[kind]}</h2>
|
| 183 |
+
<p className="shell-newdb-sub">
|
| 184 |
+
<strong>{label}</strong> — who can reach it, and what they can do with it. Sharing
|
| 185 |
+
never widens past this workspace: everyone here can already open the surface it
|
| 186 |
+
lives on.
|
| 187 |
+
</p>
|
| 188 |
+
|
| 189 |
+
{!state && !error ? (
|
| 190 |
+
<div className="shell-share-wait">
|
| 191 |
+
<span className="lp-spin" role="status" aria-label="Loading" />
|
| 192 |
+
</div>
|
| 193 |
+
) : null}
|
| 194 |
+
|
| 195 |
+
{state ? (
|
| 196 |
+
<>
|
| 197 |
+
{/* THE MANAGE-ACCESS EDITOR (item 23): the list first, because the
|
| 198 |
+
question people open this for is "who has this already" — with the
|
| 199 |
+
one-line answer above it, since the fact that changes everything
|
| 200 |
+
("Everyone can edit") is the one a list of rows buries. */}
|
| 201 |
+
<p className="shell-share-summary">{shareSummary(entries)}</p>
|
| 202 |
+
<div className="shell-share-list">
|
| 203 |
+
{state.owner ? (
|
| 204 |
+
<div className="shell-share-row is-owner">
|
| 205 |
+
<span className="shell-share-who">{nameOf(state.owner)}</span>
|
| 206 |
+
<span className="shell-share-role">Owner</span>
|
| 207 |
+
</div>
|
| 208 |
+
) : null}
|
| 209 |
+
{entries.length === 0 ? (
|
| 210 |
+
<div className="shell-share-empty">
|
| 211 |
+
Not shared with anyone yet.
|
| 212 |
+
</div>
|
| 213 |
+
) : null}
|
| 214 |
+
{entries.map((e) => (
|
| 215 |
+
<div className="shell-share-row" key={e.user}>
|
| 216 |
+
<span className="shell-share-who">{nameOf(e.user)}</span>
|
| 217 |
+
<select
|
| 218 |
+
className="shell-share-select"
|
| 219 |
+
value={e.role}
|
| 220 |
+
disabled={!mayAdminister || busy}
|
| 221 |
+
aria-label={`Role for ${nameOf(e.user)}`}
|
| 222 |
+
onChange={(ev) => {
|
| 223 |
+
const next = withEntry(entries, e.user, ev.target.value as ShareRole);
|
| 224 |
+
setEntries(next);
|
| 225 |
+
void save(next);
|
| 226 |
+
}}
|
| 227 |
+
>
|
| 228 |
+
{SHARE_ROLES.map((r) => (
|
| 229 |
+
<option key={r} value={r}>
|
| 230 |
+
{r === "edit" ? "Can edit" : "Can view"}
|
| 231 |
+
</option>
|
| 232 |
+
))}
|
| 233 |
+
</select>
|
| 234 |
+
<button
|
| 235 |
+
type="button"
|
| 236 |
+
className="shell-share-revoke"
|
| 237 |
+
disabled={!mayAdminister || busy}
|
| 238 |
+
onClick={() => {
|
| 239 |
+
const next = withoutEntry(entries, e.user);
|
| 240 |
+
setEntries(next);
|
| 241 |
+
void save(next).then((ok) => {
|
| 242 |
+
if (ok) onToast(`${nameOf(e.user)} no longer has this ${KIND_WORD[kind]}.`);
|
| 243 |
+
});
|
| 244 |
+
}}
|
| 245 |
+
>
|
| 246 |
+
Remove
|
| 247 |
+
</button>
|
| 248 |
+
</div>
|
| 249 |
+
))}
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
{mayAdminister ? (
|
| 253 |
+
<div className="shell-share-add">
|
| 254 |
+
<select
|
| 255 |
+
className="shell-share-select"
|
| 256 |
+
value={pick}
|
| 257 |
+
disabled={busy}
|
| 258 |
+
aria-label="Who to share with"
|
| 259 |
+
onChange={(e) => setPick(e.target.value)}
|
| 260 |
+
>
|
| 261 |
+
{/* ⚠ An explicit placeholder OPTION, not a blank first row: a
|
| 262 |
+
<select> whose value matches nothing renders its first option
|
| 263 |
+
while holding "", so the box would read as a chosen person and
|
| 264 |
+
the button beside it would grant somebody nobody picked
|
| 265 |
+
([[cg-condition-builder-items]]). */}
|
| 266 |
+
<option value="">Choose a person…</option>
|
| 267 |
+
<option value="*">Everyone in this workspace</option>
|
| 268 |
+
{options.map((p) => (
|
| 269 |
+
<option key={p.user} value={p.user}>
|
| 270 |
+
{p.name}
|
| 271 |
+
</option>
|
| 272 |
+
))}
|
| 273 |
+
</select>
|
| 274 |
+
<select
|
| 275 |
+
className="shell-share-select"
|
| 276 |
+
value={pickRole}
|
| 277 |
+
disabled={busy}
|
| 278 |
+
aria-label="Role for the person being added"
|
| 279 |
+
onChange={(e) => setPickRole(e.target.value as ShareRole)}
|
| 280 |
+
>
|
| 281 |
+
{SHARE_ROLES.map((r) => (
|
| 282 |
+
<option key={r} value={r}>
|
| 283 |
+
{r === "edit" ? "Can edit" : "Can view"}
|
| 284 |
+
</option>
|
| 285 |
+
))}
|
| 286 |
+
</select>
|
| 287 |
+
<button
|
| 288 |
+
type="button"
|
| 289 |
+
className="login-submit shell-share-grant"
|
| 290 |
+
disabled={busy || !pick}
|
| 291 |
+
onClick={() => {
|
| 292 |
+
const next = withEntry(entries, pick, pickRole);
|
| 293 |
+
setEntries(next);
|
| 294 |
+
void save(next).then((ok) => {
|
| 295 |
+
if (ok) {
|
| 296 |
+
onToast(`${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`);
|
| 297 |
+
setPick("");
|
| 298 |
+
}
|
| 299 |
+
});
|
| 300 |
+
}}
|
| 301 |
+
>
|
| 302 |
+
Share
|
| 303 |
+
</button>
|
| 304 |
+
<p className="shell-share-blurb">{ROLE_BLURB[kind][pickRole]}</p>
|
| 305 |
+
</div>
|
| 306 |
+
) : (
|
| 307 |
+
// ⛔ NOT A HIDDEN EDITOR — a stated refusal. A collaborator who can
|
| 308 |
+
// change this object's CONTENT still cannot change who else reaches
|
| 309 |
+
// it (the server's rule; this is the courtesy half). Saying why beats
|
| 310 |
+
// greying three controls and letting the reader guess.
|
| 311 |
+
<p className="shell-share-note">
|
| 312 |
+
Only the owner of this {KIND_WORD[kind]} — or an administrator — can change who
|
| 313 |
+
it is shared with. You can still use it as your role allows.
|
| 314 |
+
</p>
|
| 315 |
+
)}
|
| 316 |
+
</>
|
| 317 |
+
) : null}
|
| 318 |
+
|
| 319 |
+
{error ? <p className="shell-newdb-err">{error}</p> : null}
|
| 320 |
+
<div className="shell-newdb-actions">
|
| 321 |
+
<button type="button" className="login-submit" disabled={busy} onClick={onClose}>
|
| 322 |
+
Done
|
| 323 |
+
</button>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
);
|
| 328 |
+
}
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -36,15 +36,30 @@ import AutomationSurface from "../automation/AutomationSurface";
|
|
| 36 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 37 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 38 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
import { PageSurface } from "../pages/PageSurface";
|
| 41 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 42 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 43 |
import { Brand } from "./Brand";
|
| 44 |
import LoginPage from "./LoginPage";
|
| 45 |
-
import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, defaultRoute, fetchNav, fetchNavPrefs, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 46 |
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
|
| 47 |
import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
// WAVE 19 R8 — the chosen database mark. Read-only across the session fence.
|
| 49 |
import { FolderMark } from "../customer-grid/icons";
|
| 50 |
import type { FolderIcon } from "../customer-grid/types";
|
|
@@ -140,6 +155,44 @@ function ExtIcon() {
|
|
| 140 |
);
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
/** The ⋯ affordance on the account row. */
|
| 144 |
function DotsIcon() {
|
| 145 |
return (
|
|
@@ -177,6 +230,18 @@ function AutoIcon() {
|
|
| 177 |
);
|
| 178 |
}
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
/** Owner item 11 — the rail toggle: three horizontal bars. One glyph for both rails (the
|
| 181 |
* views rail draws the same geometry), so "minimize a panel" reads as one idea. */
|
| 182 |
function RailToggleIcon() {
|
|
@@ -354,6 +419,21 @@ export default function Shell() {
|
|
| 354 |
// `null` = closed. The SECTION is the open state, so "Settings" and
|
| 355 |
// "Users" are one component reached two ways rather than two dialogs.
|
| 356 |
const [settings, setSettings] = useState<SettingsSection | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
const route = useHashRoute();
|
| 358 |
// Owner items 10/11 — the navigation folds to a slim strip: by the toggle in the rail head,
|
| 359 |
// or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
|
|
@@ -389,6 +469,26 @@ export default function Shell() {
|
|
| 389 |
[]
|
| 390 |
);
|
| 391 |
const tipLeave = useCallback(() => setNavTip(null), []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
// The window signals the data layer raises (apiContract.ts). It talks to the
|
| 394 |
// frame this way because `customer-grid/**` is host-neutral — the same tree
|
|
@@ -399,15 +499,22 @@ export default function Shell() {
|
|
| 399 |
setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
|
| 400 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
| 401 |
const onNavMinimize = () => setNavCollapsed(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 403 |
window.addEventListener(DATA_ERROR_EVENT, onDataError);
|
| 404 |
window.addEventListener(TOAST_EVENT, onToast);
|
| 405 |
window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
|
|
|
| 406 |
return () => {
|
| 407 |
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 408 |
window.removeEventListener(DATA_ERROR_EVENT, onDataError);
|
| 409 |
window.removeEventListener(TOAST_EVENT, onToast);
|
| 410 |
window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
|
|
|
| 411 |
};
|
| 412 |
}, []);
|
| 413 |
|
|
@@ -593,6 +700,61 @@ export default function Shell() {
|
|
| 593 |
// holds no copy to update. Painting a new name locally would mean rendering a
|
| 594 |
// label from one source while every other reader of the nav still had the old
|
| 595 |
// one; re-asking is one cheap round trip and it cannot disagree with itself.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
const commitNavMeta = useCallback(async (key: string, patch: NavMetaPatch) => {
|
| 597 |
const ok = await saveNavMeta(key, patch);
|
| 598 |
if (ok) setNavEpoch((e) => e + 1);
|
|
@@ -644,7 +806,6 @@ export default function Shell() {
|
|
| 644 |
const [newDb, setNewDb] = useState<null | { name: string; busy: boolean; err: string }>(null);
|
| 645 |
const newDbOpenRef = useRef(false);
|
| 646 |
newDbOpenRef.current = newDb !== null;
|
| 647 |
-
const [utBusy, setUtBusy] = useState(false);
|
| 648 |
const createDb = useCallback(async () => {
|
| 649 |
setNewDb((cur) => {
|
| 650 |
if (!cur || cur.busy || !cur.name.trim()) return cur;
|
|
@@ -675,32 +836,12 @@ export default function Shell() {
|
|
| 675 |
return { ...cur, busy: true, err: "" };
|
| 676 |
});
|
| 677 |
}, []);
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
headers: { "Content-Type": "application/json" },
|
| 685 |
-
body: JSON.stringify({ values: {} }),
|
| 686 |
-
});
|
| 687 |
-
if (res.ok) {
|
| 688 |
-
// The grid's rows cache would happily serve the pre-add pull for 5 minutes —
|
| 689 |
-
// clear it, then tell the current topic to refetch (the C3-UT amendment's event).
|
| 690 |
-
clearCustomersCache();
|
| 691 |
-
window.dispatchEvent(new Event(ROWS_STALE_EVENT));
|
| 692 |
-
} else {
|
| 693 |
-
const body = (await res.json().catch(() => null)) as
|
| 694 |
-
| { error?: { message?: string } }
|
| 695 |
-
| null;
|
| 696 |
-
setToast(body?.error?.message || "The row was not added.");
|
| 697 |
-
}
|
| 698 |
-
} catch {
|
| 699 |
-
setToast("Cannot reach the server.");
|
| 700 |
-
} finally {
|
| 701 |
-
setUtBusy(false);
|
| 702 |
-
}
|
| 703 |
-
}, []);
|
| 704 |
|
| 705 |
if (session.phase === "checking") {
|
| 706 |
// Deliberately wordless. A "Loading…" line here would flash for one round
|
|
@@ -751,7 +892,13 @@ export default function Shell() {
|
|
| 751 |
|
| 752 |
return (
|
| 753 |
<div className="shell-root">
|
| 754 |
-
<aside
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
{/* The PRODUCT brand — the same mark the Streamlit host paints, from the
|
| 756 |
same generated file, so the two shells cannot drift. "Royal Imports"
|
| 757 |
stays on business documents only. */}
|
|
@@ -841,6 +988,37 @@ export default function Shell() {
|
|
| 841 |
</a>
|
| 842 |
) : null}
|
| 843 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
<div
|
| 845 |
className={
|
| 846 |
"shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
|
|
@@ -1026,6 +1204,22 @@ export default function Shell() {
|
|
| 1026 |
void commitNavMeta(item.key, { icon })
|
| 1027 |
}
|
| 1028 |
onIconClear={() => void commitNavMeta(item.key, { icon: null })}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
/>
|
| 1030 |
)}
|
| 1031 |
</div>
|
|
@@ -1152,20 +1346,14 @@ export default function Shell() {
|
|
| 1152 |
// view state never bleeds into the other's. Wave 18 (C3-UT): a `ut_` route is a
|
| 1153 |
// USER TABLE through the same tree — scope IS the key — with the shell-owned
|
| 1154 |
// Add-record bar above it (the doc's amendment: zero CustomerGrid edits).
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
disabled={utBusy}
|
| 1164 |
-
>
|
| 1165 |
-
{utBusy ? "Adding…" : "Add record"}
|
| 1166 |
-
</button>
|
| 1167 |
-
</div>
|
| 1168 |
-
) : null}
|
| 1169 |
<div className="shell-grid-host">
|
| 1170 |
<OverlayProvider>
|
| 1171 |
<CustomerGrid
|
|
@@ -1325,6 +1513,41 @@ export default function Shell() {
|
|
| 1325 |
/>
|
| 1326 |
) : null}
|
| 1327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
{/* C-SCHEMA: the database schema drawer, over everything but the toast. */}
|
| 1329 |
{schemaFor ? (
|
| 1330 |
<SchemaDrawer schemaKey={schemaFor} onClose={() => setSchemaFor(null)} />
|
|
|
|
| 36 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 37 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 38 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 39 |
+
// ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
|
| 40 |
+
// so it no longer has to tell the grid that it did.
|
| 41 |
+
import { API_V1, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
|
| 42 |
import { PageSurface } from "../pages/PageSurface";
|
| 43 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 44 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 45 |
import { Brand } from "./Brand";
|
| 46 |
import LoginPage from "./LoginPage";
|
| 47 |
+
import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, fetchNav, fetchNavPrefs, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 48 |
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
|
| 49 |
import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
|
| 50 |
+
import AlertsPane from "../alerts/AlertsPane";
|
| 51 |
+
import { createAlert, fetchInbox } from "../alerts/alertsApi";
|
| 52 |
+
import {
|
| 53 |
+
ALERT_CREATE_EVENT,
|
| 54 |
+
EMPTY_INBOX,
|
| 55 |
+
badgeText,
|
| 56 |
+
parseAlertCreate,
|
| 57 |
+
routeForTopic,
|
| 58 |
+
} from "../alerts/alertsModel";
|
| 59 |
+
import type { Inbox } from "../alerts/alertsModel";
|
| 60 |
+
import ShareDialog from "./ShareDialog";
|
| 61 |
+
import { SHARE_OPEN_EVENT, parseShareRequest } from "./shareModel";
|
| 62 |
+
import type { ShareRequest } from "./shareModel";
|
| 63 |
// WAVE 19 R8 — the chosen database mark. Read-only across the session fence.
|
| 64 |
import { FolderMark } from "../customer-grid/icons";
|
| 65 |
import type { FolderIcon } from "../customer-grid/types";
|
|
|
|
| 155 |
);
|
| 156 |
}
|
| 157 |
|
| 158 |
+
/**
|
| 159 |
+
* WAVE 20 item 4 (R8 / C-ADDROW) — THE UNIVERSAL DATABASE HEADER.
|
| 160 |
+
*
|
| 161 |
+
* One header, identical on every database (`reference/Airtable 6.png`): a chip in
|
| 162 |
+
* a bold colour carrying the database's mark, then its name. It replaces
|
| 163 |
+
* `shell-ut-bar`, which existed on USER TABLES only — so the two built-in
|
| 164 |
+
* databases had no header at all, and the one place the product named the thing
|
| 165 |
+
* you were looking at appeared or vanished depending on where the table came
|
| 166 |
+
* from. R8 makes it universal.
|
| 167 |
+
*
|
| 168 |
+
* The "Add record" button the old bar carried is GONE with it, deliberately: R8
|
| 169 |
+
* replaces it with the grid's own trailing "+" row (S3's half of C-ADDROW), on
|
| 170 |
+
* user databases only — a connector-backed table's rows are read-synced, and a
|
| 171 |
+
* "+" that must refuse is a fake affordance.
|
| 172 |
+
*
|
| 173 |
+
* The chip is decorative and says so: the name beside it is real text, so a
|
| 174 |
+
* second announcement of the same fact is noise to a screen reader.
|
| 175 |
+
*/
|
| 176 |
+
function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
|
| 177 |
+
return (
|
| 178 |
+
<div className="shell-db-head">
|
| 179 |
+
<span className={dbChipClass(icon)} aria-hidden="true">
|
| 180 |
+
{/* The database's own mark when it has one, the cylinder when it does not —
|
| 181 |
+
the same pair the rail row draws, so the header and the nav agree. Both
|
| 182 |
+
paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
|
| 183 |
+
would otherwise stroke its pastel `-deep`, which is measured against the
|
| 184 |
+
WHITE rail and disappears on its own tone. */}
|
| 185 |
+
{icon ? <FolderMark icon={icon} size={16} /> : <DbIcon />}
|
| 186 |
+
</span>
|
| 187 |
+
{/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
|
| 188 |
+
and the name of the thing you are looking at is what a heading is for. Every other
|
| 189 |
+
full-pane surface in this shell (`shell-placeholder`) already uses one, and they never
|
| 190 |
+
render together — so the page gains a heading rather than a second one. */}
|
| 191 |
+
<h1 className="shell-db-name">{label}</h1>
|
| 192 |
+
</div>
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
/** The ⋯ affordance on the account row. */
|
| 197 |
function DotsIcon() {
|
| 198 |
return (
|
|
|
|
| 230 |
);
|
| 231 |
}
|
| 232 |
|
| 233 |
+
/** WAVE 20 item 25 — the Alerts row's mark. A bell, in the same 16x16 stroke
|
| 234 |
+
* vocabulary as its neighbours, so it sits beside the sparkle and the flow rather
|
| 235 |
+
* than arriving from another icon set. */
|
| 236 |
+
function BellIcon() {
|
| 237 |
+
return (
|
| 238 |
+
<svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 239 |
+
<path d="M8 2.4a3.5 3.5 0 0 1 3.5 3.5v2.3l1.2 2H3.3l1.2-2V5.9A3.5 3.5 0 0 1 8 2.4Z" />
|
| 240 |
+
<path d="M6.6 12.4a1.5 1.5 0 0 0 2.8 0" />
|
| 241 |
+
</svg>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
/** Owner item 11 — the rail toggle: three horizontal bars. One glyph for both rails (the
|
| 246 |
* views rail draws the same geometry), so "minimize a panel" reads as one idea. */
|
| 247 |
function RailToggleIcon() {
|
|
|
|
| 419 |
// `null` = closed. The SECTION is the open state, so "Settings" and
|
| 420 |
// "Users" are one component reached two ways rather than two dialogs.
|
| 421 |
const [settings, setSettings] = useState<SettingsSection | null>(null);
|
| 422 |
+
// ── WAVE 20 items 18/23/26 (R10, C-SHARE): the access editor, for all three kinds ──
|
| 423 |
+
//
|
| 424 |
+
// The dialog is the SHELL's, and it is opened from two places that must not import
|
| 425 |
+
// each other: this frame (a database's ⋯) and the views rail (a view's or folder's
|
| 426 |
+
// ⋯), which is host-neutral `customer-grid/` code. So the rail raises a window event
|
| 427 |
+
// — the same channel the grid already uses for toasts and staleness — and the frame,
|
| 428 |
+
// which owns modals, renders it.
|
| 429 |
+
const [shareFor, setShareFor] = useState<ShareRequest | null>(null);
|
| 430 |
+
// ── WAVE 20 item 25 (C-ALERT): the inbox lives beside the nav, not in it ──────
|
| 431 |
+
//
|
| 432 |
+
// `inbox` is held HERE rather than inside the pane because the badge outlives the
|
| 433 |
+
// pane: the count has to be on screen while the panel is shut, which is the only
|
| 434 |
+
// state in which a badge is useful at all.
|
| 435 |
+
const [alertsOpen, setAlertsOpen] = useState(false);
|
| 436 |
+
const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
|
| 437 |
const route = useHashRoute();
|
| 438 |
// Owner items 10/11 — the navigation folds to a slim strip: by the toggle in the rail head,
|
| 439 |
// or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
|
|
|
|
| 469 |
[]
|
| 470 |
);
|
| 471 |
const tipLeave = useCallback(() => setNavTip(null), []);
|
| 472 |
+
// ── WAVE 20 item 7 (R9): the collapsed strip expands from its own BACKGROUND ──
|
| 473 |
+
//
|
| 474 |
+
// Until now the 56px strip had exactly one way back — the logo — and the owner
|
| 475 |
+
// kept clicking the empty space beside an icon, which did nothing at all. R9:
|
| 476 |
+
// "clicking the collapsed strip's blank/background area EXPANDS it; page icons
|
| 477 |
+
// keep navigating directly."
|
| 478 |
+
//
|
| 479 |
+
// ⚠ THE TEST IS THE TARGET, NOT THE CURRENT ELEMENT. `e.currentTarget === e.target`
|
| 480 |
+
// would only fire on the aside's own few pixels of padding — every gap the user
|
| 481 |
+
// actually clicks belongs to `.shell-nav` or `.shell-nav-list`, which are not
|
| 482 |
+
// interactive but ARE elements. So the rule is inverted: a click that landed on
|
| 483 |
+
// (or inside) a control does what that control does; anything else is background.
|
| 484 |
+
// ⛔ `closest` and not a tag check: the click usually lands on the `<svg>` or the
|
| 485 |
+
// `<span>` INSIDE the link, so testing the target's own tagName would treat every
|
| 486 |
+
// icon click as background and swallow the navigation R9 explicitly preserves.
|
| 487 |
+
const expandOnBlank = useCallback((e: ReactMouseEvent<HTMLElement>) => {
|
| 488 |
+
const hit = e.target as Element | null;
|
| 489 |
+
if (hit?.closest?.('a,button,input,textarea,select,[role="menuitem"],[role="dialog"]')) return;
|
| 490 |
+
setNavCollapsed(false);
|
| 491 |
+
}, []);
|
| 492 |
|
| 493 |
// The window signals the data layer raises (apiContract.ts). It talks to the
|
| 494 |
// frame this way because `customer-grid/**` is host-neutral — the same tree
|
|
|
|
| 499 |
setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
|
| 500 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
| 501 |
const onNavMinimize = () => setNavCollapsed(true);
|
| 502 |
+
// C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING —
|
| 503 |
+
// `parseShareRequest` fail-closes on an unknown kind rather than launching a
|
| 504 |
+
// dialog whose every save would 400.
|
| 505 |
+
const onShareOpen = (e: Event) =>
|
| 506 |
+
setShareFor(parseShareRequest((e as CustomEvent).detail));
|
| 507 |
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 508 |
window.addEventListener(DATA_ERROR_EVENT, onDataError);
|
| 509 |
window.addEventListener(TOAST_EVENT, onToast);
|
| 510 |
window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 511 |
+
window.addEventListener(SHARE_OPEN_EVENT, onShareOpen);
|
| 512 |
return () => {
|
| 513 |
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 514 |
window.removeEventListener(DATA_ERROR_EVENT, onDataError);
|
| 515 |
window.removeEventListener(TOAST_EVENT, onToast);
|
| 516 |
window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 517 |
+
window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
|
| 518 |
};
|
| 519 |
}, []);
|
| 520 |
|
|
|
|
| 700 |
// holds no copy to update. Painting a new name locally would mean rendering a
|
| 701 |
// label from one source while every other reader of the nav still had the old
|
| 702 |
// one; re-asking is one cheap round trip and it cannot disagree with itself.
|
| 703 |
+
// ── C-ALERT: the unread count, and the door that MAKES an alert ───────────────
|
| 704 |
+
//
|
| 705 |
+
// Polled once per session boot and after every write this frame knows about —
|
| 706 |
+
// never on a timer. A nav badge that re-fetches every 30 seconds is a background
|
| 707 |
+
// request per user per minute for a number nobody is looking at; the honest
|
| 708 |
+
// refresh points are "the app just started" and "you just did something".
|
| 709 |
+
useEffect(() => {
|
| 710 |
+
if (who === null) {
|
| 711 |
+
setInbox(EMPTY_INBOX);
|
| 712 |
+
return;
|
| 713 |
+
}
|
| 714 |
+
let dead = false;
|
| 715 |
+
const pull = () => {
|
| 716 |
+
void fetchInbox().then((r) => {
|
| 717 |
+
if (!dead && r.ok) setInbox(r.value);
|
| 718 |
+
});
|
| 719 |
+
};
|
| 720 |
+
pull();
|
| 721 |
+
// ...and again when the tab is looked at, which is the honest substitute for a
|
| 722 |
+
// timer: news arrives while you are elsewhere, and "elsewhere" is exactly when a
|
| 723 |
+
// poll would be wasted. One request per return to the tab, none while it sits.
|
| 724 |
+
window.addEventListener("focus", pull);
|
| 725 |
+
return () => {
|
| 726 |
+
dead = true;
|
| 727 |
+
window.removeEventListener("focus", pull);
|
| 728 |
+
};
|
| 729 |
+
}, [who]);
|
| 730 |
+
|
| 731 |
+
// The rail asks for an alert on a view; the FRAME answers, because the rail does
|
| 732 |
+
// not know its own topic — it holds views, and the scope key is the route's.
|
| 733 |
+
useEffect(() => {
|
| 734 |
+
const onCreate = (e: Event) => {
|
| 735 |
+
const req = parseAlertCreate((e as CustomEvent).detail);
|
| 736 |
+
if (!req) return;
|
| 737 |
+
const key = window.location.hash.replace(/^#\/?/, "");
|
| 738 |
+
const topic =
|
| 739 |
+
key === "product_data" ? "product" : key.startsWith("ut_") ? key : "customer";
|
| 740 |
+
void createAlert(req.viewId, topic, req.label).then((r) => {
|
| 741 |
+
if (!r.ok) {
|
| 742 |
+
// ⚠ The 400 `no_filter` is a real answer and rides through verbatim: a
|
| 743 |
+
// view with no active filter matches everything, so an alert on it could
|
| 744 |
+
// never see an entrant. "Refused, and here is why" beats a dead bell.
|
| 745 |
+
setToast(r.message);
|
| 746 |
+
return;
|
| 747 |
+
}
|
| 748 |
+
setToast(`Alerting on "${req.label}". New records that enter it appear under Alerts.`);
|
| 749 |
+
void fetchInbox().then((got) => {
|
| 750 |
+
if (got.ok) setInbox(got.value);
|
| 751 |
+
});
|
| 752 |
+
});
|
| 753 |
+
};
|
| 754 |
+
window.addEventListener(ALERT_CREATE_EVENT, onCreate);
|
| 755 |
+
return () => window.removeEventListener(ALERT_CREATE_EVENT, onCreate);
|
| 756 |
+
}, []);
|
| 757 |
+
|
| 758 |
const commitNavMeta = useCallback(async (key: string, patch: NavMetaPatch) => {
|
| 759 |
const ok = await saveNavMeta(key, patch);
|
| 760 |
if (ok) setNavEpoch((e) => e + 1);
|
|
|
|
| 806 |
const [newDb, setNewDb] = useState<null | { name: string; busy: boolean; err: string }>(null);
|
| 807 |
const newDbOpenRef = useRef(false);
|
| 808 |
newDbOpenRef.current = newDb !== null;
|
|
|
|
| 809 |
const createDb = useCallback(async () => {
|
| 810 |
setNewDb((cur) => {
|
| 811 |
if (!cur || cur.busy || !cur.name.trim()) return cur;
|
|
|
|
| 836 |
return { ...cur, busy: true, err: "" };
|
| 837 |
});
|
| 838 |
}, []);
|
| 839 |
+
// ⛔ `addRecord` LIVED HERE AND IS DELETED WITH THE BAR THAT CALLED IT (wave 20 item 4,
|
| 840 |
+
// R8 / C-ADDROW). The shell was POSTing `/tables/{key}/rows` itself, clearing the grid's
|
| 841 |
+
// cache and firing ROWS_STALE_EVENT to make the new row appear — a write path owned by
|
| 842 |
+
// the frame, for a surface the grid owns. R8 moves the affordance into the grid as a
|
| 843 |
+
// trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
|
| 844 |
+
// the shell stops being a second writer of table data.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
|
| 846 |
if (session.phase === "checking") {
|
| 847 |
// Deliberately wordless. A "Loading…" line here would flash for one round
|
|
|
|
| 892 |
|
| 893 |
return (
|
| 894 |
<div className="shell-root">
|
| 895 |
+
<aside
|
| 896 |
+
className={"shell-side" + (navCollapsed ? " is-collapsed" : "")}
|
| 897 |
+
// Item 7 (R9) — background click expands. Bound only while collapsed, so the
|
| 898 |
+
// open rail is exactly what it was; the accessible route stays the labelled
|
| 899 |
+
// brand button below ("Expand navigation"), which is what a keyboard reaches.
|
| 900 |
+
onClick={navCollapsed ? expandOnBlank : undefined}
|
| 901 |
+
>
|
| 902 |
{/* The PRODUCT brand — the same mark the Streamlit host paints, from the
|
| 903 |
same generated file, so the two shells cannot drift. "Royal Imports"
|
| 904 |
stays on business documents only. */}
|
|
|
|
| 988 |
</a>
|
| 989 |
) : null}
|
| 990 |
|
| 991 |
+
{/* WAVE 20 item 25 (C-ALERT) — "Alerts", directly under Automation and above
|
| 992 |
+
the database band, with the unread count on it.
|
| 993 |
+
⛔ A BUTTON, NOT A NAV LINK, and the distinction is this shell's oldest
|
| 994 |
+
rule: the nav is server-filtered and an undeclared surface is denied, so a
|
| 995 |
+
client-invented `#/alerts` route would be exactly the hard-coded surface
|
| 996 |
+
the frame refuses to have. An inbox is not a granted module — it is this
|
| 997 |
+
account's own notifications about its own views — so it opens a panel, the
|
| 998 |
+
way the AI assistant does. */}
|
| 999 |
+
<button
|
| 1000 |
+
type="button"
|
| 1001 |
+
className={"shell-nav-item shell-nav-alerts" + (alertsOpen ? " is-active" : "")}
|
| 1002 |
+
aria-haspopup="dialog"
|
| 1003 |
+
aria-expanded={alertsOpen}
|
| 1004 |
+
onMouseEnter={navCollapsed ? tipEnter("Alerts") : undefined}
|
| 1005 |
+
onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| 1006 |
+
onClick={() => setAlertsOpen(true)}
|
| 1007 |
+
>
|
| 1008 |
+
<BellIcon />
|
| 1009 |
+
<span className="shell-nav-label">Alerts</span>
|
| 1010 |
+
{badgeText(inbox.unread) ? (
|
| 1011 |
+
<span
|
| 1012 |
+
className="shell-nav-badge"
|
| 1013 |
+
// Not aria-hidden: the count IS the information, and a badge a screen
|
| 1014 |
+
// reader cannot see makes the row read as an empty inbox.
|
| 1015 |
+
aria-label={`${inbox.unread} unread`}
|
| 1016 |
+
>
|
| 1017 |
+
{badgeText(inbox.unread)}
|
| 1018 |
+
</span>
|
| 1019 |
+
) : null}
|
| 1020 |
+
</button>
|
| 1021 |
+
|
| 1022 |
<div
|
| 1023 |
className={
|
| 1024 |
"shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
|
|
|
|
| 1204 |
void commitNavMeta(item.key, { icon })
|
| 1205 |
}
|
| 1206 |
onIconClear={() => void commitNavMeta(item.key, { icon: null })}
|
| 1207 |
+
// WAVE 20 item 18 (R10) — share THIS database. Offered on the
|
| 1208 |
+
// user's own tables only: `customer_data` and `product_data` are
|
| 1209 |
+
// registry surfaces whose reach is the permission wall's answer,
|
| 1210 |
+
// not one person's to grant ([[aios-permission-wall]]), and a
|
| 1211 |
+
// dialog that recorded a grant the module gate would then ignore
|
| 1212 |
+
// is the "shared, silently inert" failure in reverse.
|
| 1213 |
+
{...(item.key.startsWith("ut_")
|
| 1214 |
+
? {
|
| 1215 |
+
onShare: () =>
|
| 1216 |
+
setShareFor({
|
| 1217 |
+
kind: "database",
|
| 1218 |
+
id: item.key,
|
| 1219 |
+
label: item.label,
|
| 1220 |
+
}),
|
| 1221 |
+
}
|
| 1222 |
+
: {})}
|
| 1223 |
/>
|
| 1224 |
)}
|
| 1225 |
</div>
|
|
|
|
| 1346 |
// view state never bleeds into the other's. Wave 18 (C3-UT): a `ut_` route is a
|
| 1347 |
// USER TABLE through the same tree — scope IS the key — with the shell-owned
|
| 1348 |
// Add-record bar above it (the doc's amendment: zero CustomerGrid edits).
|
| 1349 |
+
// WAVE 20 item 4 (R8): ONE frame for every database. The old branch gave user
|
| 1350 |
+
// tables a header-plus-grid frame and the built-ins a bare grid, which is why
|
| 1351 |
+
// Customer and Product had nowhere to put a name.
|
| 1352 |
+
<div className="shell-db-frame">
|
| 1353 |
+
<DbHead
|
| 1354 |
+
label={active.label}
|
| 1355 |
+
{...(active.icon ? { icon: active.icon } : {})}
|
| 1356 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1357 |
<div className="shell-grid-host">
|
| 1358 |
<OverlayProvider>
|
| 1359 |
<CustomerGrid
|
|
|
|
| 1513 |
/>
|
| 1514 |
) : null}
|
| 1515 |
|
| 1516 |
+
{/* C-ALERT (item 25): the inbox. */}
|
| 1517 |
+
{alertsOpen ? (
|
| 1518 |
+
<AlertsPane
|
| 1519 |
+
onClose={() => setAlertsOpen(false)}
|
| 1520 |
+
onInbox={setInbox}
|
| 1521 |
+
onToast={setToast}
|
| 1522 |
+
onOpenView={(topic, viewId) => {
|
| 1523 |
+
const route = routeForTopic(topic);
|
| 1524 |
+
if (!route) return;
|
| 1525 |
+
window.location.hash = `#/${route}`;
|
| 1526 |
+
// ⚠ HALF A CLICK-THROUGH UNTIL S3 LISTENS (amendment A-S4-4): this lands
|
| 1527 |
+
// the reader on the right TABLE and asks the grid to select the view. The
|
| 1528 |
+
// grid owns view selection — its `activeViewId` is seeded from its own
|
| 1529 |
+
// storage key, which this frame does not know — so until the listener
|
| 1530 |
+
// exists the request is simply not heard. Navigating anyway is the honest
|
| 1531 |
+
// half: a notification that took you nowhere would be worse.
|
| 1532 |
+
window.dispatchEvent(
|
| 1533 |
+
new CustomEvent("aios:view-open", { detail: { topic, viewId } })
|
| 1534 |
+
);
|
| 1535 |
+
}}
|
| 1536 |
+
/>
|
| 1537 |
+
) : null}
|
| 1538 |
+
|
| 1539 |
+
{/* C-SHARE (items 18/23/26): the access editor — one dialog, three kinds. */}
|
| 1540 |
+
{shareFor ? (
|
| 1541 |
+
<ShareDialog
|
| 1542 |
+
kind={shareFor.kind}
|
| 1543 |
+
id={shareFor.id}
|
| 1544 |
+
label={shareFor.label}
|
| 1545 |
+
me={session.user.username}
|
| 1546 |
+
onClose={() => setShareFor(null)}
|
| 1547 |
+
onToast={setToast}
|
| 1548 |
+
/>
|
| 1549 |
+
) : null}
|
| 1550 |
+
|
| 1551 |
{/* C-SCHEMA: the database schema drawer, over everything but the toast. */}
|
| 1552 |
{schemaFor ? (
|
| 1553 |
<SchemaDrawer schemaKey={schemaFor} onClose={() => setSchemaFor(null)} />
|
web/src/shell/nav.ts
CHANGED
|
@@ -97,6 +97,35 @@ export function parseNavIcon(raw: unknown): FolderIcon | undefined {
|
|
| 97 |
return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] };
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
/**
|
| 101 |
* Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather
|
| 102 |
* than rendered: a nav row with no name is a door with no sign on it, and
|
|
|
|
| 97 |
return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] };
|
| 98 |
}
|
| 99 |
|
| 100 |
+
/**
|
| 101 |
+
* WAVE 20 item 4 (R8) — the class list for the universal database header's icon
|
| 102 |
+
* chip: `shell-db-chip`, plus a tone modifier when the database wears a mark.
|
| 103 |
+
*
|
| 104 |
+
* R8 asks for "an icon chip on a bold colour background on EVERY database", and
|
| 105 |
+
* that sentence hides a decision the JSX would otherwise bury: what colour does a
|
| 106 |
+
* database that never chose one get? Three cases, and they are here rather than
|
| 107 |
+
* inline so a gate can hold them (and so the answer is written once, not once per
|
| 108 |
+
* render branch):
|
| 109 |
+
*
|
| 110 |
+
* · no icon at all — every built-in and every un-styled table — takes the BASE
|
| 111 |
+
* chip, which the stylesheet paints in the brand primary. That is the
|
| 112 |
+
* "sensible default where unset" the ruling asks for, and it is why the base
|
| 113 |
+
* class carries a colour instead of leaving the chip transparent.
|
| 114 |
+
* · an icon takes its own TONE at the bold `-deep` weight, so the header agrees
|
| 115 |
+
* with the mark the same database wears in the rail.
|
| 116 |
+
* · an UNRECOGNISED tone falls back to the base, never `--unknown`: emitting a
|
| 117 |
+
* class no stylesheet defines would paint a chip with no background at all,
|
| 118 |
+
* and a white glyph on white is an invisible header. Same fail-safe posture
|
| 119 |
+
* as `parseNavIcon` above — a drifted vocabulary reverts to the default mark
|
| 120 |
+
* rather than breaking the frame.
|
| 121 |
+
*/
|
| 122 |
+
export function dbChipClass(icon?: FolderIcon): string {
|
| 123 |
+
const base = "shell-db-chip";
|
| 124 |
+
const tone = icon?.tone;
|
| 125 |
+
if (!tone || !(FOLDER_TONES as readonly string[]).includes(tone)) return base;
|
| 126 |
+
return `${base} ${base}--${tone}`;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
/**
|
| 130 |
* Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather
|
| 131 |
* than rendered: a nav row with no name is a door with no sign on it, and
|
web/src/shell/shareModel.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// shell/shareModel.ts — WAVE 20 items 18/23/26 (owner ruling R10, contract
|
| 3 |
+
// C-SHARE): the manage-access editor's PURE half.
|
| 4 |
+
//
|
| 5 |
+
// Reading and editing a grant set is arithmetic over a list, and every way it
|
| 6 |
+
// can be wrong is silent:
|
| 7 |
+
//
|
| 8 |
+
// · a grant stored against the wrong identifier binds to nobody (see the
|
| 9 |
+
// mailbox amendment A-S4-5 — `people` may arrive as display names while
|
| 10 |
+
// every server-side check compares login usernames);
|
| 11 |
+
// · a PUT that REPLACES, given a list assembled by halves, revokes people
|
| 12 |
+
// nobody meant to revoke — the API has no DELETE verb, so an omission IS a
|
| 13 |
+
// revocation and every edit must carry the whole set forward;
|
| 14 |
+
// · `mayAdminister` read loosely turns "you may edit this view" into "you may
|
| 15 |
+
// decide who else can", which is the privilege escalation R10's server half
|
| 16 |
+
// refuses in as many words.
|
| 17 |
+
//
|
| 18 |
+
// So the model is React-free and lives here, where `verify_login.py` — this
|
| 19 |
+
// codebase's ACCESS gate — can run it under node with negative controls. The
|
| 20 |
+
// dialog is a renderer over these functions and decides nothing.
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
/** The three shareable kinds. Closed vocabulary: the server 400s anything else. */
|
| 24 |
+
export const SHARE_KINDS = ["view", "folder", "database"] as const;
|
| 25 |
+
export type ShareKind = (typeof SHARE_KINDS)[number];
|
| 26 |
+
|
| 27 |
+
/** The two roles, the same two words the view rail already speaks (R10). */
|
| 28 |
+
export const SHARE_ROLES = ["view", "edit"] as const;
|
| 29 |
+
export type ShareRole = (typeof SHARE_ROLES)[number];
|
| 30 |
+
|
| 31 |
+
/** The wildcard entry: "everyone who can already open this surface". */
|
| 32 |
+
export const EVERYONE = "*";
|
| 33 |
+
|
| 34 |
+
export interface ShareEntry {
|
| 35 |
+
user: string;
|
| 36 |
+
role: ShareRole;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** One assignable account. `name` is what a human reads; `user` is what binds. */
|
| 40 |
+
export interface SharePerson {
|
| 41 |
+
user: string;
|
| 42 |
+
name: string;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export interface ShareState {
|
| 46 |
+
owner: string | null;
|
| 47 |
+
entries: ShareEntry[];
|
| 48 |
+
/** This session's own role on the object: 'owner' | 'edit' | 'view' | null. */
|
| 49 |
+
role: string | null;
|
| 50 |
+
/** Owner or admin — the only people the server lets change grants. */
|
| 51 |
+
mayAdminister: boolean;
|
| 52 |
+
people: SharePerson[];
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* `GET /api/v1/share/{kind}/{oid}` → a state the editor can render, FAIL-CLOSED.
|
| 59 |
+
*
|
| 60 |
+
* Two absences matter and they are not the same:
|
| 61 |
+
* · a missing `entries` list is an object nobody has shared yet — an empty
|
| 62 |
+
* editor, which is correct and ordinary;
|
| 63 |
+
* · a missing `mayAdminister` is a server that did not answer the question,
|
| 64 |
+
* and it reads as NO. Defaulting it to yes would open the editor for a
|
| 65 |
+
* collaborator, who would then meet a 403 on save — or, worse, would meet a
|
| 66 |
+
* server that had also been relaxed.
|
| 67 |
+
*/
|
| 68 |
+
export function parseShare(body: unknown): ShareState {
|
| 69 |
+
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
|
| 70 |
+
const rawEntries = Array.isArray(b.entries) ? b.entries : [];
|
| 71 |
+
const entries: ShareEntry[] = [];
|
| 72 |
+
const seen = new Set<string>();
|
| 73 |
+
for (const item of rawEntries) {
|
| 74 |
+
if (!item || typeof item !== "object") continue;
|
| 75 |
+
const e = item as Record<string, unknown>;
|
| 76 |
+
const user = str(e.user).toLowerCase();
|
| 77 |
+
const role = str(e.role).toLowerCase();
|
| 78 |
+
// Same posture as the server's `_clean_entries`: a junk ROLE is dropped, never
|
| 79 |
+
// coerced to a default — a row shown as "can view" that the store holds as
|
| 80 |
+
// something else is a lie the editor would then save back.
|
| 81 |
+
if (!user || !(SHARE_ROLES as readonly string[]).includes(role) || seen.has(user)) continue;
|
| 82 |
+
seen.add(user);
|
| 83 |
+
entries.push({ user, role: role as ShareRole });
|
| 84 |
+
}
|
| 85 |
+
return {
|
| 86 |
+
owner: str(b.owner).toLowerCase() || null,
|
| 87 |
+
entries,
|
| 88 |
+
role: str(b.role).toLowerCase() || null,
|
| 89 |
+
mayAdminister: b.mayAdminister === true,
|
| 90 |
+
people: parsePeople(b.people),
|
| 91 |
+
};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* The assignable accounts.
|
| 96 |
+
*
|
| 97 |
+
* ⚠ TWO SHAPES ACCEPTED, AND THE REASON IS A LIVE CONTRACT QUESTION (A-S4-5).
|
| 98 |
+
* The route serves `users.assignable_people()`, which returns DISPLAY NAMES,
|
| 99 |
+
* while every grant check compares LOGIN USERNAMES — so a picker that stores
|
| 100 |
+
* what it was shown can write a grant that binds to nobody. An object entry
|
| 101 |
+
* (`{username, name}`) resolves that; a bare string is read as a username,
|
| 102 |
+
* because that is the only reading under which the current payload is correct.
|
| 103 |
+
* Nothing here can repair a display name: no payload maps one to an account.
|
| 104 |
+
*/
|
| 105 |
+
export function parsePeople(raw: unknown): SharePerson[] {
|
| 106 |
+
if (!Array.isArray(raw)) return [];
|
| 107 |
+
const out: SharePerson[] = [];
|
| 108 |
+
const seen = new Set<string>();
|
| 109 |
+
for (const item of raw) {
|
| 110 |
+
let user = "";
|
| 111 |
+
let name = "";
|
| 112 |
+
if (typeof item === "string") {
|
| 113 |
+
user = item.trim();
|
| 114 |
+
name = user;
|
| 115 |
+
} else if (item && typeof item === "object") {
|
| 116 |
+
const p = item as Record<string, unknown>;
|
| 117 |
+
user = str(p.username) || str(p.user);
|
| 118 |
+
name = str(p.name) || user;
|
| 119 |
+
}
|
| 120 |
+
const key = user.toLowerCase();
|
| 121 |
+
if (!key || seen.has(key)) continue;
|
| 122 |
+
seen.add(key);
|
| 123 |
+
out.push({ user: key, name: name || user });
|
| 124 |
+
}
|
| 125 |
+
return out;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* Add a person, or change the role of one already listed. The whole list comes
|
| 130 |
+
* back, because the PUT replaces: an editor that returned only its delta would
|
| 131 |
+
* revoke everyone it forgot to mention.
|
| 132 |
+
*/
|
| 133 |
+
export function withEntry(entries: ShareEntry[], user: string, role: ShareRole): ShareEntry[] {
|
| 134 |
+
const key = str(user).toLowerCase();
|
| 135 |
+
if (!key || !(SHARE_ROLES as readonly string[]).includes(role)) return entries;
|
| 136 |
+
let found = false;
|
| 137 |
+
const next = entries.map((e) => {
|
| 138 |
+
if (e.user !== key) return e;
|
| 139 |
+
found = true;
|
| 140 |
+
return { user: key, role };
|
| 141 |
+
});
|
| 142 |
+
return found ? next : [...next, { user: key, role }];
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/** Revoke: the entry's ABSENCE is the revocation (there is no DELETE verb). */
|
| 146 |
+
export function withoutEntry(entries: ShareEntry[], user: string): ShareEntry[] {
|
| 147 |
+
const key = str(user).toLowerCase();
|
| 148 |
+
return entries.filter((e) => e.user !== key);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/** The people not yet granted anything — what the "add someone" picker offers. */
|
| 152 |
+
export function addablePeople(people: SharePerson[], entries: ShareEntry[]): SharePerson[] {
|
| 153 |
+
const taken = new Set(entries.map((e) => e.user));
|
| 154 |
+
return people.filter((p) => !taken.has(p.user));
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* One sentence naming who can reach this object, for the row that opens the
|
| 159 |
+
* editor. Never a count on its own: "3 people" reads as reassurance, and the
|
| 160 |
+
* fact that matters is whether one of them is EVERYONE.
|
| 161 |
+
*/
|
| 162 |
+
export function shareSummary(entries: ShareEntry[]): string {
|
| 163 |
+
if (!entries.length) return "Not shared";
|
| 164 |
+
const everyone = entries.find((e) => e.user === EVERYONE);
|
| 165 |
+
if (everyone) {
|
| 166 |
+
return everyone.role === "edit" ? "Everyone can edit" : "Everyone can view";
|
| 167 |
+
}
|
| 168 |
+
const editors = entries.filter((e) => e.role === "edit").length;
|
| 169 |
+
const people = entries.length === 1 ? "1 person" : `${entries.length} people`;
|
| 170 |
+
return editors ? `${people}, ${editors} can edit` : `${people} can view`;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/** The PUT body. One shape, one place — the editor never assembles it inline. */
|
| 174 |
+
export function sharePutBody(entries: ShareEntry[]): { entries: ShareEntry[] } {
|
| 175 |
+
return { entries };
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* The shell↔rail channel for "open the access editor for this thing".
|
| 180 |
+
*
|
| 181 |
+
* A window event rather than a prop, because the two callers live in trees that
|
| 182 |
+
* must not import each other: the views rail is host-neutral `customer-grid/`
|
| 183 |
+
* and the dialog is the shell's. The name is a literal in both files until S3
|
| 184 |
+
* publishes it in `apiContract.ts` (amendment A-S4-4) — the constant is here so
|
| 185 |
+
* only one file in this tree spells it.
|
| 186 |
+
*/
|
| 187 |
+
export const SHARE_OPEN_EVENT = "aios:share-open";
|
| 188 |
+
|
| 189 |
+
export interface ShareRequest {
|
| 190 |
+
kind: ShareKind;
|
| 191 |
+
id: string;
|
| 192 |
+
/** What to call the thing in the dialog's title — the user's word for it. */
|
| 193 |
+
label: string;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/** Read a `SHARE_OPEN_EVENT` detail, dropping anything malformed (fail-closed:
|
| 197 |
+
* an unknown kind is not a new namespace, it is a bug that must not open a
|
| 198 |
+
* dialog that would 400 on save). */
|
| 199 |
+
export function parseShareRequest(detail: unknown): ShareRequest | null {
|
| 200 |
+
if (!detail || typeof detail !== "object") return null;
|
| 201 |
+
const d = detail as Record<string, unknown>;
|
| 202 |
+
const kind = str(d.kind).toLowerCase();
|
| 203 |
+
const id = str(d.id);
|
| 204 |
+
if (!id || !(SHARE_KINDS as readonly string[]).includes(kind)) return null;
|
| 205 |
+
return { kind: kind as ShareKind, id, label: str(d.label) || id };
|
| 206 |
+
}
|