Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/deps.py +20 -0
- api/main.py +14 -10
- api/routes_automation.py +7 -9
- api/routes_changes.py +8 -4
- api/routes_forms.py +9 -1
- api/routes_nav.py +16 -9
- api/routes_odoo_tables.py +22 -4
- api/routes_platform_admin.py +60 -41
- api/routes_tables.py +33 -24
- api/routes_templates.py +5 -2
- platform/aios_grid.py +16 -8
- platform/core/store_pg.py +6 -1
- web/src/customer-grid/apiBridge.ts +69 -69
- web/src/customer-grid/iconShapes.ts +18 -7
- web/src/customer-grid/icons.tsx +4 -4
- web/src/settings/AdminPane.tsx +7 -6
- web/src/settings/platformAdminApi.ts +7 -3
api/deps.py
CHANGED
|
@@ -27,6 +27,7 @@ if str(_RI) not in sys.path:
|
|
| 27 |
sys.path.insert(0, str(_RI))
|
| 28 |
|
| 29 |
import core.perms as perms # noqa: E402
|
|
|
|
| 30 |
import core.users as users # noqa: E402
|
| 31 |
from harness import runtime # noqa: E402
|
| 32 |
|
|
@@ -663,6 +664,25 @@ def require_session(request: Request, response: Response) -> Session:
|
|
| 663 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 664 |
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
def module_gate(module_key):
|
| 667 |
"""A dependency that 401s without a session and 403s without the grant for `module_key`."""
|
| 668 |
def _dep(session: Session = Depends(require_session)) -> Session:
|
|
|
|
| 27 |
sys.path.insert(0, str(_RI))
|
| 28 |
|
| 29 |
import core.perms as perms # noqa: E402
|
| 30 |
+
import core.store as store # noqa: E402
|
| 31 |
import core.users as users # noqa: E402
|
| 32 |
from harness import runtime # noqa: E402
|
| 33 |
|
|
|
|
| 664 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 665 |
|
| 666 |
|
| 667 |
+
def require_changes_session(request: Request, response: Response) -> Session | None:
|
| 668 |
+
"""Authenticate the passive Pg change endpoint without reopening the database.
|
| 669 |
+
|
| 670 |
+
Pg change tokens are intentionally disabled: a current client refreshes the data it is viewing
|
| 671 |
+
on focus, while an already-open legacy client may still call this route every ten seconds. A
|
| 672 |
+
verified signed cookie is enough to return that no-data response; resolving the account record
|
| 673 |
+
would itself turn those legacy requests into a permanent Neon keepalive.
|
| 674 |
+
"""
|
| 675 |
+
if store.backend() != "pg":
|
| 676 |
+
return require_session(request, response)
|
| 677 |
+
if request.headers.get("x-aios-change-check") == "focus-v1":
|
| 678 |
+
return require_session(request, response)
|
| 679 |
+
raw = request.cookies.get(aios_session.COOKIE_NAME)
|
| 680 |
+
claims = aios_session.read(raw) if raw else None
|
| 681 |
+
if not claims or not str(claims.get("t") or "").strip() or not str(claims.get("u") or "").strip():
|
| 682 |
+
raise err(401, "invalid_session", "your session has expired — sign in again")
|
| 683 |
+
return None
|
| 684 |
+
|
| 685 |
+
|
| 686 |
def module_gate(module_key):
|
| 687 |
"""A dependency that 401s without a session and 403s without the grant for `module_key`."""
|
| 688 |
def _dep(session: Session = Depends(require_session)) -> Session:
|
api/main.py
CHANGED
|
@@ -1083,13 +1083,12 @@ def _store_resync_loop():
|
|
| 1083 |
def _prewarm():
|
| 1084 |
import time as _t
|
| 1085 |
t0 = _t.time()
|
| 1086 |
-
#
|
| 1087 |
-
#
|
| 1088 |
-
#
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
_th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
|
| 1093 |
try:
|
| 1094 |
from harness import runtime as _runtime
|
| 1095 |
rt = _runtime.get_runtime("royal-imports")
|
|
@@ -1220,6 +1219,11 @@ if (os.environ.get("AIOS_ENABLE_QA_TENANT") == "1"
|
|
| 1220 |
name="qa-sandbox-schema").start()
|
| 1221 |
|
| 1222 |
if os.environ.get("AIOS_PREWARM") == "1":
|
| 1223 |
-
import threading as _threading
|
| 1224 |
-
_threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
|
| 1225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
def _prewarm():
|
| 1084 |
import time as _t
|
| 1085 |
t0 = _t.time()
|
| 1086 |
+
# The Odoo seed/sync can run for minutes and is separate from the finite cache warm below.
|
| 1087 |
+
# Keep it opt-in: Live Pg serves its seeded analytical mirror at boot and lets explicit
|
| 1088 |
+
# workers perform real sync work, so a routine restart can still scale Neon back to zero.
|
| 1089 |
+
if os.environ.get("AIOS_BOOT_SYNC") == "1":
|
| 1090 |
+
import threading as _th
|
| 1091 |
+
_th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
|
|
|
|
| 1092 |
try:
|
| 1093 |
from harness import runtime as _runtime
|
| 1094 |
rt = _runtime.get_runtime("royal-imports")
|
|
|
|
| 1219 |
name="qa-sandbox-schema").start()
|
| 1220 |
|
| 1221 |
if os.environ.get("AIOS_PREWARM") == "1":
|
| 1222 |
+
import threading as _threading
|
| 1223 |
+
_threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
|
| 1224 |
+
|
| 1225 |
+
# A boot prewarm is finite; the Odoo/store resync loop is resident work. Keep the latter
|
| 1226 |
+
# opt-in so a Pg-backed Live Space can scale its Neon compute to zero between real requests.
|
| 1227 |
+
if os.environ.get("AIOS_STORE_RESYNC") == "1":
|
| 1228 |
+
import threading as _threading_resync
|
| 1229 |
+
_threading_resync.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()
|
api/routes_automation.py
CHANGED
|
@@ -5,11 +5,9 @@ cron, a URL the SSRF rail refuses, a key field that is not one of the mapped col
|
|
| 5 |
in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file
|
| 6 |
does auth, shape and status codes.
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
rather than admitting everyone. A "no token configured means no check" default is how an internal
|
| 12 |
-
trigger becomes a public one — the same class of mistake as an empty-200 permission answer.
|
| 13 |
"""
|
| 14 |
import os
|
| 15 |
import re
|
|
@@ -1878,10 +1876,10 @@ def run_rows(auto_id: str, session: Session = Depends(_GATE)):
|
|
| 1878 |
# The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in
|
| 1879 |
# verify_automation.py section T rather than assumed.
|
| 1880 |
#
|
| 1881 |
-
#
|
| 1882 |
-
#
|
| 1883 |
-
#
|
| 1884 |
-
engine.start_scheduler()
|
| 1885 |
|
| 1886 |
# C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module
|
| 1887 |
# is where the registration belongs — it is the one place that already imports both sides, so
|
|
|
|
| 5 |
in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file
|
| 6 |
does auth, shape and status codes.
|
| 7 |
|
| 8 |
+
The former unauthenticated tick endpoint is permanently retired and returns HTTP 410. Production
|
| 9 |
+
definitions now reconcile exact native schedules through `automation_control`; direct events start
|
| 10 |
+
one finite worker immediately. No shared tick secret or polling clock remains.
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
import os
|
| 13 |
import re
|
|
|
|
| 1876 |
# The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in
|
| 1877 |
# verify_automation.py section T rather than assumed.
|
| 1878 |
#
|
| 1879 |
+
# Production Pg refuses this resident path even if `AIOS_AUTOMATIONS` drifts on. Exact AWS
|
| 1880 |
+
# schedules and direct dispatch use `automation_control`; this call survives only for isolated
|
| 1881 |
+
# local/HF development fixtures that explicitly opt in.
|
| 1882 |
+
engine.start_scheduler()
|
| 1883 |
|
| 1884 |
# C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module
|
| 1885 |
# is where the registration belongs — it is the one place that already imports both sides, so
|
api/routes_changes.py
CHANGED
|
@@ -63,7 +63,7 @@ import core.user_tables as user_tables
|
|
| 63 |
import modules.customer_data as customer_data
|
| 64 |
import modules.product_data as product_data
|
| 65 |
|
| 66 |
-
from deps import Session, err, require_session
|
| 67 |
|
| 68 |
router = APIRouter(prefix="/api/v1")
|
| 69 |
|
|
@@ -198,16 +198,20 @@ def _may_watch(session, scope):
|
|
| 198 |
|
| 199 |
|
| 200 |
@router.get("/changes")
|
| 201 |
-
def changes(scope: str = "", session: Session = Depends(
|
| 202 |
-
"""
|
| 203 |
names = buckets_for(scope)
|
| 204 |
if names is None:
|
| 205 |
raise err(400, "bad_scope", "name the table you are watching, e.g. ?scope=customer")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
# ⛔ W29-T82 — THE SAME REFUSAL EVERY SIBLING DOOR GIVES, word for word. A session that may
|
| 207 |
# not open this table must not be able to tell whether it exists, and "404 unknown_table" is
|
| 208 |
# the sentence the other five row doors answer with. A different code here would itself be
|
| 209 |
# the tell.
|
| 210 |
-
if names and not _may_watch(session, _clean_scope(scope)):
|
| 211 |
raise err(404, "unknown_table", f"no database {_clean_scope(scope)!r} here")
|
| 212 |
if not names:
|
| 213 |
# ⛔ A well-formed scope this map does not know is a 404, NOT an empty `tokens` object.
|
|
|
|
| 63 |
import modules.customer_data as customer_data
|
| 64 |
import modules.product_data as product_data
|
| 65 |
|
| 66 |
+
from deps import Session, err, require_changes_session, require_session
|
| 67 |
|
| 68 |
router = APIRouter(prefix="/api/v1")
|
| 69 |
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
@router.get("/changes")
|
| 201 |
+
def changes(scope: str = "", session: Session | None = Depends(require_changes_session)):
|
| 202 |
+
"""Serve legacy change checks without making Pg a resident client."""
|
| 203 |
names = buckets_for(scope)
|
| 204 |
if names is None:
|
| 205 |
raise err(400, "bad_scope", "name the table you are watching, e.g. ?scope=customer")
|
| 206 |
+
# Pg clients refresh data on user focus. Returning null tokens is database-free so an old
|
| 207 |
+
# browser bundle cannot keep Neon awake while the current no-poll bundle rolls out.
|
| 208 |
+
if names and store.backend() == "pg" and session is None:
|
| 209 |
+
return {"scope": _clean_scope(scope), "tokens": {label: None for label in names}, "backend": "pg"}
|
| 210 |
# ⛔ W29-T82 — THE SAME REFUSAL EVERY SIBLING DOOR GIVES, word for word. A session that may
|
| 211 |
# not open this table must not be able to tell whether it exists, and "404 unknown_table" is
|
| 212 |
# the sentence the other five row doors answer with. A different code here would itself be
|
| 213 |
# the tell.
|
| 214 |
+
if names and (session is None or not _may_watch(session, _clean_scope(scope))):
|
| 215 |
raise err(404, "unknown_table", f"no database {_clean_scope(scope)!r} here")
|
| 216 |
if not names:
|
| 217 |
# ⛔ A well-formed scope this map does not know is a 404, NOT an empty `tokens` object.
|
api/routes_forms.py
CHANGED
|
@@ -231,7 +231,15 @@ def _public_form(rt, table_key: str, spec: dict) -> dict:
|
|
| 231 |
import aios_grid
|
| 232 |
import core.user_tables as user_tables
|
| 233 |
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
|
| 236 |
required = {str(k) for k in (spec.get("required") or [])}
|
| 237 |
out = []
|
|
|
|
| 231 |
import aios_grid
|
| 232 |
import core.user_tables as user_tables
|
| 233 |
|
| 234 |
+
# A public form renders its declared fields only; no submitted table row belongs on this
|
| 235 |
+
# path. In production `rt` is tenant-bound, so lend the rows-free projection. The `None`
|
| 236 |
+
# fallback preserves the isolated helper fixture used by the form verifier.
|
| 237 |
+
lend_defs = getattr(user_tables, 'lend_defs', None)
|
| 238 |
+
# Some isolated route verifiers supply a deliberately tiny `user_tables` double. Production
|
| 239 |
+
# always has `lend_defs`; preserving the double's direct handle keeps that fixture focused on
|
| 240 |
+
# its public-form contract rather than forcing it to impersonate the entire store API.
|
| 241 |
+
definitions = lend_defs(rt) if rt is not None and callable(lend_defs) else rt
|
| 242 |
+
defn = user_tables.get(table_key, st=definitions) or {}
|
| 243 |
by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
|
| 244 |
required = {str(k) for k in (spec.get("required") or [])}
|
| 245 |
out = []
|
api/routes_nav.py
CHANGED
|
@@ -764,10 +764,14 @@ def save_nav_meta(body: dict = Body(default=None),
|
|
| 764 |
# because a user table is not a module. Same split `/nav/schema/{key}` makes.
|
| 765 |
# · everything else — admin only, and icon only. There is no owner of `customer_data` to
|
| 766 |
# defer to, and its label is a compiled registry literal (refused below regardless).
|
| 767 |
-
if key.startswith("ut_"):
|
| 768 |
-
import core.user_tables as user_tables
|
| 769 |
-
|
| 770 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 772 |
else:
|
| 773 |
if not session.admin:
|
|
@@ -886,11 +890,14 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
|
|
| 886 |
# Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
|
| 887 |
# (`may_open`) rather than the module grant machinery — `session.require` would 403 every
|
| 888 |
# ut key because a user table is deliberately not a module.
|
| 889 |
-
if key.startswith("ut_"):
|
| 890 |
-
import core.user_tables as user_tables
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
|
|
|
|
|
|
|
|
|
| 894 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 895 |
# ⭐⭐ W38-T18 — THE FIELD WALL ON A `ut_*` DATABASE, WHICH THIS DOOR HAS NEVER RUN.
|
| 896 |
# `may_open` answers *IF* you reach the database and says nothing about WHICH COLUMNS, so
|
|
|
|
| 764 |
# because a user table is not a module. Same split `/nav/schema/{key}` makes.
|
| 765 |
# · everything else — admin only, and icon only. There is no owner of `customer_data` to
|
| 766 |
# defer to, and its label is a compiled registry literal (refused below regardless).
|
| 767 |
+
if key.startswith("ut_"):
|
| 768 |
+
import core.user_tables as user_tables
|
| 769 |
+
# This write edits metadata, never a record. The table wall therefore needs its
|
| 770 |
+
# definition only; lending the rows-free JSONB projection keeps a rename/icon click from
|
| 771 |
+
# copying every table row just to prove the caller owns this database.
|
| 772 |
+
definitions = user_tables.lend_defs(session.runtime)
|
| 773 |
+
if not user_tables.get(key, st=definitions) or not user_tables.may_open(
|
| 774 |
+
key, session.uname, session.admin, st=definitions):
|
| 775 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 776 |
else:
|
| 777 |
if not session.admin:
|
|
|
|
| 890 |
# Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
|
| 891 |
# (`may_open`) rather than the module grant machinery — `session.require` would 403 every
|
| 892 |
# ut key because a user table is deliberately not a module.
|
| 893 |
+
if key.startswith("ut_"):
|
| 894 |
+
import core.user_tables as user_tables
|
| 895 |
+
# Schema is definition-only by contract. Do not pay a whole `user_tables` read merely
|
| 896 |
+
# to open the drawer; the rows-free projection is also lent to the canonical wall.
|
| 897 |
+
definitions = user_tables.lend_defs(session.runtime)
|
| 898 |
+
defn = user_tables.get(key, st=definitions)
|
| 899 |
+
if not defn or not user_tables.may_open(key, session.uname, session.admin,
|
| 900 |
+
st=definitions):
|
| 901 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 902 |
# ⭐⭐ W38-T18 — THE FIELD WALL ON A `ut_*` DATABASE, WHICH THIS DOOR HAS NEVER RUN.
|
| 903 |
# `may_open` answers *IF* you reach the database and says nothing about WHICH COLUMNS, so
|
api/routes_odoo_tables.py
CHANGED
|
@@ -153,15 +153,33 @@ def refresh_odoo_tables(session: Session = Depends(require_session)):
|
|
| 153 |
|
| 154 |
|
| 155 |
@router.get("/odoo-tables/status")
|
| 156 |
-
def odoo_tables_status(
|
|
|
|
| 157 |
"""Row counts + the newest `refreshed` stamp per table.
|
| 158 |
|
| 159 |
⚠ The stamp is the whole point while the resync wiring is outstanding: a locked collections
|
| 160 |
worklist that quietly stopped updating is a worklist that lies, so its age must be readable
|
| 161 |
without anybody running a refresh to find out.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
"""
|
| 163 |
-
rel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
import core.user_tables as user_tables
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
# ⭐ W30-T31 — THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
|
| 166 |
# the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
|
| 167 |
# store that is not ready costs the size half of the question, never the whole endpoint.
|
|
@@ -187,7 +205,7 @@ def odoo_tables_status(session: Session = Depends(require_session)):
|
|
| 187 |
# hard-coded list here would have reported "everything is fine" over two databases it had
|
| 188 |
# stopped knowing about ([[gate-answers-the-wrong-question]] — the hard-coded count).
|
| 189 |
for _bucket, key, _label, _fields in rel.TABLES:
|
| 190 |
-
table =
|
| 191 |
rows = (table.get("rows") or {})
|
| 192 |
stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
|
| 193 |
# ⛔⛔ A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
|
|
@@ -196,7 +214,7 @@ def odoo_tables_status(session: Session = Depends(require_session)):
|
|
| 196 |
# operator would see a database that looks EMPTY and STALE on a route whose own docstring
|
| 197 |
# says a worklist that quietly stopped updating must be legible without a refresh. So a
|
| 198 |
# read-through table is counted from the MIRROR and says where its count came from.
|
| 199 |
-
materialised = user_tables.materialises(key,
|
| 200 |
if not materialised:
|
| 201 |
try:
|
| 202 |
rows = {}
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
@router.get("/odoo-tables/status")
|
| 156 |
+
def odoo_tables_status(brief: bool = Query(False),
|
| 157 |
+
session: Session = Depends(require_session)):
|
| 158 |
"""Row counts + the newest `refreshed` stamp per table.
|
| 159 |
|
| 160 |
⚠ The stamp is the whole point while the resync wiring is outstanding: a locked collections
|
| 161 |
worklist that quietly stopped updating is a worklist that lies, so its age must be readable
|
| 162 |
without anybody running a refresh to find out.
|
| 163 |
+
|
| 164 |
+
`brief=1` is the grid's one question at open time: which declared tables read through the
|
| 165 |
+
mirror? It deliberately does not borrow the tenant document, take a mirror cursor, or count
|
| 166 |
+
rows. Those would be work solely for metadata the client never reads. The source registry
|
| 167 |
+
remains the authority, so a future conversion is still discovered server-side rather than by
|
| 168 |
+
a client-side key list.
|
| 169 |
"""
|
| 170 |
+
rel = _rel()
|
| 171 |
+
if brief:
|
| 172 |
+
sources = _sources()
|
| 173 |
+
return {"ok": True, "tables": {
|
| 174 |
+
key: {"readThrough": key in sources}
|
| 175 |
+
for _bucket, key, _label, _fields in rel.TABLES
|
| 176 |
+
}}
|
| 177 |
+
|
| 178 |
import core.user_tables as user_tables
|
| 179 |
+
# The detailed operator view genuinely needs rows to report counts/freshness, but it needs
|
| 180 |
+
# them ONCE. Repeated `get()` calls each copy the same tenant document under the store lock.
|
| 181 |
+
tables = user_tables.all_tables(st=session.runtime)
|
| 182 |
+
out = {}
|
| 183 |
# ⭐ W30-T31 — THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
|
| 184 |
# the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
|
| 185 |
# store that is not ready costs the size half of the question, never the whole endpoint.
|
|
|
|
| 205 |
# hard-coded list here would have reported "everything is fine" over two databases it had
|
| 206 |
# stopped knowing about ([[gate-answers-the-wrong-question]] — the hard-coded count).
|
| 207 |
for _bucket, key, _label, _fields in rel.TABLES:
|
| 208 |
+
table = tables.get(key) or {}
|
| 209 |
rows = (table.get("rows") or {})
|
| 210 |
stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
|
| 211 |
# ⛔⛔ A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
|
|
|
|
| 214 |
# operator would see a database that looks EMPTY and STALE on a route whose own docstring
|
| 215 |
# says a worklist that quietly stopped updating must be legible without a refresh. So a
|
| 216 |
# read-through table is counted from the MIRROR and says where its count came from.
|
| 217 |
+
materialised = user_tables.materialises(key, defn=table)
|
| 218 |
if not materialised:
|
| 219 |
try:
|
| 220 |
rows = {}
|
api/routes_platform_admin.py
CHANGED
|
@@ -251,15 +251,18 @@ def _connector_rows(slug, rt):
|
|
| 251 |
|
| 252 |
# ══════════════════════════════════════════════════════════════════════════ automations + cost
|
| 253 |
|
| 254 |
-
#:
|
| 255 |
-
#:
|
| 256 |
-
#:
|
| 257 |
-
#:
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
| 263 |
DAYS_PER_MONTH = 30.4
|
| 264 |
|
| 265 |
|
|
@@ -299,6 +302,7 @@ def _runs_per_day(cron):
|
|
| 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))
|
|
@@ -309,18 +313,33 @@ def _automation_rows(slug, rt):
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 "",
|
|
@@ -334,40 +353,34 @@ def _automation_rows(slug, rt):
|
|
| 334 |
|
| 335 |
|
| 336 |
def _automation_cost(auto_rows):
|
| 337 |
-
"""
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
| 355 |
"invocationsPerMonth": int(round(invocations)),
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
"lambdaMb": LAMBDA_MB,
|
| 359 |
-
"usd": 0.0,
|
| 360 |
"fleetRunsPerDay": round(fleet_runs, 2),
|
| 361 |
"unknownCadence": unknown_cadence,
|
| 362 |
"basis": (
|
| 363 |
-
f"
|
| 364 |
-
f"
|
| 365 |
-
f"
|
| 366 |
-
f"{
|
| 367 |
-
f"
|
| 368 |
-
f"
|
| 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 |
|
|
@@ -581,18 +594,24 @@ def platform_automations(tenant: str = "", session: Session = Depends(padmin_gat
|
|
| 581 |
continue
|
| 582 |
retained = max_runs
|
| 583 |
out += rows
|
| 584 |
-
|
|
|
|
|
|
|
| 585 |
return {"automations": out, "count": len(out),
|
| 586 |
"enabled": sum(1 for a in out if a["enabled"]),
|
| 587 |
"historyRetained": retained,
|
| 588 |
"cost": _automation_cost(out), "errors": errors,
|
| 589 |
-
"tickEnabled":
|
| 590 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
|
| 592 |
|
| 593 |
@router.get("/aws")
|
| 594 |
def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
|
| 595 |
-
"""
|
| 596 |
days = max(1, min(int(days or 7), 90))
|
| 597 |
return {"days": days, "report": _aws_report(days)}
|
| 598 |
|
|
|
|
| 251 |
|
| 252 |
# ══════════════════════════════════════════════════════════════════════════ automations + cost
|
| 253 |
|
| 254 |
+
#: 2026-08-21 public us-east-2 Linux/x86 rates for the deployed 0.25-vCPU / 1-GiB task.
|
| 255 |
+
#: This is a recurring-run FLOOR, not an invoice: Linux Fargate has a 60-second minimum and the
|
| 256 |
+
#: task receives one public IPv4 address while it runs. The measured `/aws` report is authoritative.
|
| 257 |
+
#: Sources: https://aws.amazon.com/fargate/pricing/ and https://aws.amazon.com/vpc/pricing/
|
| 258 |
+
FARGATE_VCPU_USD_HOUR = 0.04048
|
| 259 |
+
FARGATE_GB_USD_HOUR = 0.004445
|
| 260 |
+
PUBLIC_IPV4_USD_HOUR = 0.005
|
| 261 |
+
TASK_VCPU = 0.25
|
| 262 |
+
TASK_GB = 1.0
|
| 263 |
+
MIN_BILLED_SECONDS = 60
|
| 264 |
+
MIN_RUN_USD = ((TASK_VCPU * FARGATE_VCPU_USD_HOUR + TASK_GB * FARGATE_GB_USD_HOUR
|
| 265 |
+
+ PUBLIC_IPV4_USD_HOUR) * MIN_BILLED_SECONDS / 3600.0)
|
| 266 |
DAYS_PER_MONTH = 30.4
|
| 267 |
|
| 268 |
|
|
|
|
| 302 |
def _automation_rows(slug, rt):
|
| 303 |
"""A tenant's automations, their schedules, and their retained run history."""
|
| 304 |
try:
|
| 305 |
+
import automation_control as control
|
| 306 |
import automation_engine as engine
|
| 307 |
defs = engine.all_definitions(rt) or {}
|
| 308 |
max_runs = int(getattr(engine, "MAX_RUNS", 20))
|
|
|
|
| 313 |
if not isinstance(d, dict):
|
| 314 |
continue
|
| 315 |
sched = d.get("schedule") or {}
|
| 316 |
+
trigger = d.get("trigger") or {}
|
| 317 |
status = d.get("status") or {}
|
| 318 |
runs = list(d.get("runs") or [])
|
| 319 |
cron = sched.get("cron") or ""
|
| 320 |
+
schedule_per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
|
| 321 |
+
email_enabled = (trigger.get("key") == "email" and trigger.get("enabled", True)
|
| 322 |
+
and not trigger.get("paused") and trigger.get("configured", True))
|
| 323 |
+
per_day = (None if schedule_per_day is None
|
| 324 |
+
else schedule_per_day + (96.0 if email_enabled else 0.0))
|
| 325 |
+
specs_error = False
|
| 326 |
+
try:
|
| 327 |
+
specs = control.recurring_specs(slug, d)
|
| 328 |
+
except Exception:
|
| 329 |
+
# A stored legacy/custom cron must read as unknown, never as zero cost.
|
| 330 |
+
specs = []
|
| 331 |
+
specs_error = True
|
| 332 |
+
per_day = None
|
| 333 |
rows.append({
|
| 334 |
"tenant": slug,
|
| 335 |
"id": auto_id,
|
| 336 |
"name": d.get("name") or auto_id,
|
| 337 |
"kind": d.get("kind") or "",
|
| 338 |
+
"enabled": bool(specs) or specs_error,
|
| 339 |
"cron": cron,
|
| 340 |
"runsPerDay": round(per_day, 2) if per_day is not None else None,
|
| 341 |
+
"nativeSchedules": None if specs_error else len(specs),
|
| 342 |
+
"scheduleModes": sorted({str(s.get("mode") or "") for s in specs}),
|
| 343 |
"state": status.get("state") or "idle",
|
| 344 |
"lastRunAt": status.get("lastRunAt") or "",
|
| 345 |
"lastSummary": status.get("lastSummary") or "",
|
|
|
|
| 353 |
|
| 354 |
|
| 355 |
def _automation_cost(auto_rows):
|
| 356 |
+
"""Minimum scheduled-run estimate for the exact-schedule, finite-worker architecture.
|
| 357 |
+
|
| 358 |
+
It includes the 60-second Fargate floor plus one temporary public IPv4 allocation. It excludes
|
| 359 |
+
unknown direct/manual events, one-shot vendor collections, Step Functions transitions above
|
| 360 |
+
its free allowance, task runtime beyond 60 seconds, and Neon compute/transfer. `/aws` is the
|
| 361 |
+
measured source of truth; this is only a transparent planning floor.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
"""
|
|
|
|
| 363 |
fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
|
| 364 |
unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
|
| 365 |
+
native_schedules = sum(int(r.get("nativeSchedules") or 0) for r in auto_rows)
|
| 366 |
+
invocations = fleet_runs * DAYS_PER_MONTH
|
| 367 |
+
minimum_usd = invocations * MIN_RUN_USD
|
| 368 |
return {
|
| 369 |
+
"mode": "exact-schedules-fargate",
|
| 370 |
+
"cadence": "per automation",
|
| 371 |
+
"nativeSchedules": native_schedules,
|
| 372 |
"invocationsPerMonth": int(round(invocations)),
|
| 373 |
+
"perRunMinimumUsd": round(MIN_RUN_USD, 8),
|
| 374 |
+
"usd": round(minimum_usd, 4),
|
|
|
|
|
|
|
| 375 |
"fleetRunsPerDay": round(fleet_runs, 2),
|
| 376 |
"unknownCadence": unknown_cadence,
|
| 377 |
"basis": (
|
| 378 |
+
f"{native_schedules} exact native schedule(s) imply about "
|
| 379 |
+
f"{int(round(invocations)):,} finite run(s)/month. At the 60-second Fargate plus "
|
| 380 |
+
f"public-IPv4 minimum (${MIN_RUN_USD:.8f}/run), the recurring floor is "
|
| 381 |
+
f"about ${minimum_usd:.4f}/month. There is no resident ECS service. Direct/manual "
|
| 382 |
+
f"runs, one-shot vendor collection, longer tasks, paid Step Functions transitions "
|
| 383 |
+
f"and Neon are excluded; the AWS report shows measured cost."
|
|
|
|
|
|
|
| 384 |
),
|
| 385 |
}
|
| 386 |
|
|
|
|
| 594 |
continue
|
| 595 |
retained = max_runs
|
| 596 |
out += rows
|
| 597 |
+
import automation_control as control
|
| 598 |
+
resident = engine.scheduler_status()
|
| 599 |
+
exact_enabled = control.configured()
|
| 600 |
return {"automations": out, "count": len(out),
|
| 601 |
"enabled": sum(1 for a in out if a["enabled"]),
|
| 602 |
"historyRetained": retained,
|
| 603 |
"cost": _automation_cost(out), "errors": errors,
|
| 604 |
+
"tickEnabled": False,
|
| 605 |
+
"controlEnabled": exact_enabled,
|
| 606 |
+
"executionMode": "exact-aws" if exact_enabled else "local-disabled",
|
| 607 |
+
"scheduler": {"enabled": exact_enabled,
|
| 608 |
+
"source": "exact-aws" if exact_enabled else "",
|
| 609 |
+
"resident": resident}}
|
| 610 |
|
| 611 |
|
| 612 |
@router.get("/aws")
|
| 613 |
def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
|
| 614 |
+
"""AWS automation usage, verbatim, or an honest note explaining its absence."""
|
| 615 |
days = max(1, min(int(days or 7), 90))
|
| 616 |
return {"days": days, "report": _aws_report(days)}
|
| 617 |
|
api/routes_tables.py
CHANGED
|
@@ -39,7 +39,7 @@ import threading
|
|
| 39 |
import json
|
| 40 |
import time
|
| 41 |
|
| 42 |
-
from fastapi import Body, Depends
|
| 43 |
from fastapi import APIRouter
|
| 44 |
|
| 45 |
from deps import Session, err, require_session
|
|
@@ -1073,9 +1073,10 @@ def nav_meta(session):
|
|
| 1073 |
return {}
|
| 1074 |
|
| 1075 |
|
| 1076 |
-
@router.get("/tables")
|
| 1077 |
-
def list_tables(
|
| 1078 |
-
|
|
|
|
| 1079 |
|
| 1080 |
⭐⭐ W31-T12 (contract C1, D-175's third instance) — ONE DOCUMENT READ, NOT `1 + 2N`. This
|
| 1081 |
route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:
|
|
@@ -1083,26 +1084,34 @@ def list_tables(session: Session = Depends(require_session)):
|
|
| 1083 |
tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that — so a tenant
|
| 1084 |
with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked
|
| 1085 |
about every table; it is handed the document this function already holds. See
|
| 1086 |
-
`user_tables.lend`'s own note for why inlining the predicate is the one fix that is not
|
| 1087 |
-
available.
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
|
| 1107 |
|
| 1108 |
#: ⭐⭐ THE ROLLUP SOURCE OFFER — what makes the read-through rollup a FEATURE rather than a
|
|
|
|
| 39 |
import json
|
| 40 |
import time
|
| 41 |
|
| 42 |
+
from fastapi import Body, Depends, Query
|
| 43 |
from fastapi import APIRouter
|
| 44 |
|
| 45 |
from deps import Session, err, require_session
|
|
|
|
| 1073 |
return {}
|
| 1074 |
|
| 1075 |
|
| 1076 |
+
@router.get("/tables")
|
| 1077 |
+
def list_tables(brief: bool = Query(False),
|
| 1078 |
+
session: Session = Depends(require_session)):
|
| 1079 |
+
"""This session's user tables — the list the '+ New database' surface renders.
|
| 1080 |
|
| 1081 |
⭐⭐ W31-T12 (contract C1, D-175's third instance) — ONE DOCUMENT READ, NOT `1 + 2N`. This
|
| 1082 |
route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:
|
|
|
|
| 1084 |
tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that — so a tenant
|
| 1085 |
with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked
|
| 1086 |
about every table; it is handed the document this function already holds. See
|
| 1087 |
+
`user_tables.lend`'s own note for why inlining the predicate is the one fix that is not
|
| 1088 |
+
available.
|
| 1089 |
+
|
| 1090 |
+
`brief=1` is for a link-field picker. It needs the tables it may target and their schemas,
|
| 1091 |
+
not row counts or record mutability. Reading a full tenant document to decorate that one
|
| 1092 |
+
field editor would move every stored row through Neon for no visible result, so this branch
|
| 1093 |
+
keeps the server-side permission wall but lends its rows-free projection instead.
|
| 1094 |
+
"""
|
| 1095 |
+
ut = _ut()
|
| 1096 |
+
meta = nav_meta(session)
|
| 1097 |
+
out = []
|
| 1098 |
+
tables = (ut.all_defs(st=session.runtime) if brief
|
| 1099 |
+
else ut.all_tables(st=session.runtime))
|
| 1100 |
+
lent = ut.lend(session.runtime, **{ut.STORE_KEY: tables})
|
| 1101 |
+
for key, t in sorted(tables.items(),
|
| 1102 |
+
key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
|
| 1103 |
+
if not ut.may_open(key, session.uname, session.admin, st=lent):
|
| 1104 |
+
continue
|
| 1105 |
+
row = {"key": key, "label": ut_label(t, key, meta),
|
| 1106 |
+
"fields": [dict(f) for f in (t.get("fields") or [])]}
|
| 1107 |
+
if not brief:
|
| 1108 |
+
row.update({"source": t.get("source") or "Blank",
|
| 1109 |
+
"recordsMutable": ut.records_mutable(key, st=lent),
|
| 1110 |
+
"createdBy": t.get("createdBy") or "",
|
| 1111 |
+
"created": t.get("created") or "",
|
| 1112 |
+
"rowCount": len(t.get("rows") or {})})
|
| 1113 |
+
out.append(row)
|
| 1114 |
+
return {"tables": out}
|
| 1115 |
|
| 1116 |
|
| 1117 |
#: ⭐⭐ THE ROLLUP SOURCE OFFER — what makes the read-through rollup a FEATURE rather than a
|
api/routes_templates.py
CHANGED
|
@@ -49,10 +49,13 @@ def _target_or_refuse(session, table_key):
|
|
| 49 |
raise err(400, "bad_request", "no database was named")
|
| 50 |
if key.startswith('ut_'):
|
| 51 |
import core.user_tables as user_tables
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
if not defn:
|
| 54 |
raise err(404, "unknown_table", "that database does not exist")
|
| 55 |
-
if not user_tables.may_open(key, session.uname, session.admin, st=
|
| 56 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 57 |
fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
|
| 58 |
# A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
|
|
|
|
| 49 |
raise err(400, "bad_request", "no database was named")
|
| 50 |
if key.startswith('ut_'):
|
| 51 |
import core.user_tables as user_tables
|
| 52 |
+
# A template negotiates a field contract, never record values. Keep the table wall on
|
| 53 |
+
# the same rows-free projection as navigation rather than copying the tenant document.
|
| 54 |
+
definitions = user_tables.lend_defs(session.runtime)
|
| 55 |
+
defn = user_tables.get(key, st=definitions)
|
| 56 |
if not defn:
|
| 57 |
raise err(404, "unknown_table", "that database does not exist")
|
| 58 |
+
if not user_tables.may_open(key, session.uname, session.admin, st=definitions):
|
| 59 |
raise err(403, "forbidden", "that database belongs to another user")
|
| 60 |
fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
|
| 61 |
# A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
|
platform/aios_grid.py
CHANGED
|
@@ -1918,11 +1918,14 @@ MAX_FOLDER_NAME = 80
|
|
| 1918 |
#: 12-shape guess of mine that contained shapes the client cannot draw). Do not extend this set
|
| 1919 |
#: without the matching client geometry: an unknown shape falls back to the default folder mark,
|
| 1920 |
#: which is also I14's "existing folders get the folder icon" for every pre-wave-9 folder.
|
| 1921 |
-
FOLDER_ICON_SHAPES = {
|
| 1922 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
|
|
|
|
|
|
|
|
|
| 1926 |
|
| 1927 |
|
| 1928 |
#: Wave-9 I17 (contract C4) — who may EDIT a saved view.
|
|
@@ -1982,9 +1985,14 @@ def clean_folder_icon(raw):
|
|
| 1982 |
shape = raw.get("shape")
|
| 1983 |
if shape not in FOLDER_ICON_SHAPES:
|
| 1984 |
return None
|
| 1985 |
-
tone = raw.get("tone")
|
| 1986 |
-
|
| 1987 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1988 |
|
| 1989 |
|
| 1990 |
def clean_folders(raw):
|
|
|
|
| 1918 |
#: 12-shape guess of mine that contained shapes the client cannot draw). Do not extend this set
|
| 1919 |
#: without the matching client geometry: an unknown shape falls back to the default folder mark,
|
| 1920 |
#: which is also I14's "existing folders get the folder icon" for every pre-wave-9 folder.
|
| 1921 |
+
FOLDER_ICON_SHAPES = {
|
| 1922 |
+
"folder", "star", "flag", "tag", "bookmark", "grid", "chart", "map", "users",
|
| 1923 |
+
"clock", "heart", "bolt",
|
| 1924 |
+
}
|
| 1925 |
+
#: Tones are the C1 pastels — FILLS ONLY, never text. This mirrors the current client vocabulary;
|
| 1926 |
+
#: the old host spelling `grey` is accepted as a read/write migration alias for `neutral` below.
|
| 1927 |
+
FOLDER_ICON_TONES = {"neutral", "blue", "green", "yellow", "red"}
|
| 1928 |
+
FOLDER_ICON_DEFAULT_TONE = "neutral"
|
| 1929 |
|
| 1930 |
|
| 1931 |
#: Wave-9 I17 (contract C4) — who may EDIT a saved view.
|
|
|
|
| 1985 |
shape = raw.get("shape")
|
| 1986 |
if shape not in FOLDER_ICON_SHAPES:
|
| 1987 |
return None
|
| 1988 |
+
tone = raw.get("tone")
|
| 1989 |
+
# The first host implementation called the neutral tone `grey`. Accept that legacy value so
|
| 1990 |
+
# an existing folder cannot crash a newer client, but canonicalise it on the next workspace
|
| 1991 |
+
# write/read to the current shared vocabulary.
|
| 1992 |
+
if tone == "grey":
|
| 1993 |
+
tone = FOLDER_ICON_DEFAULT_TONE
|
| 1994 |
+
return {"shape": shape,
|
| 1995 |
+
"tone": tone if tone in FOLDER_ICON_TONES else FOLDER_ICON_DEFAULT_TONE}
|
| 1996 |
|
| 1997 |
|
| 1998 |
def clean_folders(raw):
|
platform/core/store_pg.py
CHANGED
|
@@ -230,7 +230,12 @@ def _pool():
|
|
| 230 |
pass
|
| 231 |
# min_size=0 so a process that never touches the store opens no connection at all — the
|
| 232 |
# shared API serves plenty of requests (health, static, a cached payload) that never read.
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
_POOL['url'] = url
|
| 235 |
return _POOL['pool']
|
| 236 |
|
|
|
|
| 230 |
pass
|
| 231 |
# min_size=0 so a process that never touches the store opens no connection at all — the
|
| 232 |
# shared API serves plenty of requests (health, static, a cached payload) that never read.
|
| 233 |
+
# Returned connections are deliberately short-lived: Neon's compute should suspend between
|
| 234 |
+
# real requests, not be held awake by this process's idle pool for psycopg's 10-minute
|
| 235 |
+
# default. Thirty seconds still reuses a normal request burst without creating a resident
|
| 236 |
+
# database client.
|
| 237 |
+
_POOL['pool'] = ConnectionPool(url, min_size=0, max_size=8, open=True, timeout=10.0,
|
| 238 |
+
max_idle=30.0)
|
| 239 |
_POOL['url'] = url
|
| 240 |
return _POOL['pool']
|
| 241 |
|
web/src/customer-grid/apiBridge.ts
CHANGED
|
@@ -52,9 +52,9 @@ import {
|
|
| 52 |
checkTenant,
|
| 53 |
signal,
|
| 54 |
} from "../apiContract";
|
| 55 |
-
import { setStandaloneSink } from "./hostBridge";
|
| 56 |
-
import { changedBuckets } from "./liveWorkspace";
|
| 57 |
-
import type { ChangeTokens } from "./liveWorkspace";
|
| 58 |
import { CUSTOMER_TOPIC } from "./types";
|
| 59 |
import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row,
|
| 60 |
SortSpec, TopicConfig } from "./types";
|
|
@@ -439,7 +439,9 @@ export function readThroughTables(): Promise<Set<string>> {
|
|
| 439 |
if (!readThroughMemo) {
|
| 440 |
readThroughMemo = (async () => {
|
| 441 |
try {
|
| 442 |
-
|
|
|
|
|
|
|
| 443 |
if (handledSession(res) || !res.ok) {
|
| 444 |
readThroughMemo = null;
|
| 445 |
return new Set<string>();
|
|
@@ -594,8 +596,10 @@ export async function fetchWorkspace(
|
|
| 594 |
export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | null> {
|
| 595 |
let res: Response;
|
| 596 |
try {
|
| 597 |
-
res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`,
|
| 598 |
-
|
|
|
|
|
|
|
| 599 |
} catch {
|
| 600 |
return null;
|
| 601 |
}
|
|
@@ -610,89 +614,83 @@ export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | n
|
|
| 610 |
return out;
|
| 611 |
}
|
| 612 |
|
| 613 |
-
/**
|
| 614 |
-
*
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
inFlight: boolean;
|
|
|
|
| 622 |
/** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler
|
| 623 |
* call the SAME function. The visibility handler used to re-inline the body, which quietly
|
| 624 |
* skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second
|
| 625 |
* request on top of it, exactly what that guard exists to prevent. */
|
| 626 |
-
poll: () => Promise<void>;
|
| 627 |
}
|
| 628 |
|
| 629 |
-
const changeWatches = new Map<string, ChangeWatch>();
|
| 630 |
-
let visibilityBound = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
|
| 632 |
/**
|
| 633 |
* Watch one scope for server-side changes. Returns its unsubscribe.
|
| 634 |
*
|
| 635 |
-
* ⭐ ONE
|
| 636 |
-
*
|
| 637 |
-
*
|
| 638 |
-
*
|
| 639 |
-
*
|
| 640 |
-
*
|
| 641 |
-
* tab left open overnight would otherwise spend the night asking a question nobody can see the
|
| 642 |
-
* answer to, and the one moment its answer certainly matters is the moment you look at it.
|
| 643 |
-
* `visibilitychange` had zero listeners in this client before this.
|
| 644 |
*/
|
| 645 |
export function subscribeChanges(scope: string,
|
| 646 |
onChange: (changed: string[]) => void): () => void {
|
| 647 |
if (typeof window === "undefined") return () => {};
|
| 648 |
let watch = changeWatches.get(scope);
|
| 649 |
if (!watch) {
|
| 650 |
-
const w0: ChangeWatch = {
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
};
|
| 667 |
watch = w0;
|
| 668 |
changeWatches.set(scope, w0);
|
| 669 |
}
|
| 670 |
-
const w = watch;
|
| 671 |
-
w.listeners.add(onChange);
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
if (!visibilityBound) {
|
| 678 |
-
visibilityBound = true;
|
| 679 |
-
window.addEventListener("visibilitychange",
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
if (watched.timer) void watched.poll();
|
| 683 |
-
});
|
| 684 |
-
}
|
| 685 |
|
| 686 |
return () => {
|
| 687 |
w.listeners.delete(onChange);
|
| 688 |
-
if (w.listeners.size === 0
|
| 689 |
-
clearInterval(w.timer);
|
| 690 |
-
w.timer = null;
|
| 691 |
// ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than
|
| 692 |
-
// compare against a token from before it was unmounted
|
| 693 |
-
|
| 694 |
-
// full read of what it just read.
|
| 695 |
-
w.tokens = null;
|
| 696 |
}
|
| 697 |
};
|
| 698 |
}
|
|
@@ -872,9 +870,11 @@ export async function addTableRow(
|
|
| 872 |
* and an unreachable server — and a create pane that explodes because a picker could not fetch
|
| 873 |
* is worse than one that says it has nothing to offer.
|
| 874 |
*/
|
| 875 |
-
export async function fetchLinkTargets(): Promise<LinkTarget[]> {
|
| 876 |
-
try {
|
| 877 |
-
|
|
|
|
|
|
|
| 878 |
if (handledSession(res) || !res.ok) return [];
|
| 879 |
const body = (await readJson(res)) as { tables?: LinkTarget[] } | null;
|
| 880 |
return Array.isArray(body?.tables) ? body.tables : [];
|
|
|
|
| 52 |
checkTenant,
|
| 53 |
signal,
|
| 54 |
} from "../apiContract";
|
| 55 |
+
import { setStandaloneSink } from "./hostBridge";
|
| 56 |
+
import { changedBuckets } from "./liveWorkspace";
|
| 57 |
+
import type { ChangeTokens } from "./liveWorkspace";
|
| 58 |
import { CUSTOMER_TOPIC } from "./types";
|
| 59 |
import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row,
|
| 60 |
SortSpec, TopicConfig } from "./types";
|
|
|
|
| 439 |
if (!readThroughMemo) {
|
| 440 |
readThroughMemo = (async () => {
|
| 441 |
try {
|
| 442 |
+
// The grid needs only the read-through flag, never the operator status's counts/stamps.
|
| 443 |
+
// `brief=1` is server-derived from the connected-grid registry and reads no table rows.
|
| 444 |
+
const res = await fetch(`${API_V1}/odoo-tables/status?brief=1`, { credentials: CREDENTIALS });
|
| 445 |
if (handledSession(res) || !res.ok) {
|
| 446 |
readThroughMemo = null;
|
| 447 |
return new Set<string>();
|
|
|
|
| 596 |
export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | null> {
|
| 597 |
let res: Response;
|
| 598 |
try {
|
| 599 |
+
res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`,
|
| 600 |
+
// This header marks a current, user-return check. Legacy bundles lack it,
|
| 601 |
+
// so their former 10-second requests remain database-free on Pg.
|
| 602 |
+
{ credentials: CREDENTIALS, headers: { "X-AIOS-Change-Check": "focus-v1" } });
|
| 603 |
} catch {
|
| 604 |
return null;
|
| 605 |
}
|
|
|
|
| 614 |
return out;
|
| 615 |
}
|
| 616 |
|
| 617 |
+
/**
|
| 618 |
+
* A grid reloads only when the person returns to the tab or window. There is no timer and no
|
| 619 |
+
* background token request.
|
| 620 |
+
*/
|
| 621 |
+
|
| 622 |
+
interface ChangeWatch {
|
| 623 |
+
listeners: Set<(changed: string[]) => void>;
|
| 624 |
+
tokens: ChangeTokens | null;
|
| 625 |
+
inFlight: boolean;
|
| 626 |
+
refresh: () => Promise<void>;
|
| 627 |
/** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler
|
| 628 |
* call the SAME function. The visibility handler used to re-inline the body, which quietly
|
| 629 |
* skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second
|
| 630 |
* request on top of it, exactly what that guard exists to prevent. */
|
|
|
|
| 631 |
}
|
| 632 |
|
| 633 |
+
const changeWatches = new Map<string, ChangeWatch>();
|
| 634 |
+
let visibilityBound = false;
|
| 635 |
+
function refreshVisibleChanges(): void {
|
| 636 |
+
if (document.visibilityState !== "visible") return;
|
| 637 |
+
for (const watched of changeWatches.values())
|
| 638 |
+
void watched.refresh();
|
| 639 |
+
}
|
| 640 |
|
| 641 |
/**
|
| 642 |
* Watch one scope for server-side changes. Returns its unsubscribe.
|
| 643 |
*
|
| 644 |
+
* ⭐ ONE WATCH PER SCOPE, REFCOUNTED. A linked-record grid can mount a second `useCustomerData` on
|
| 645 |
+
* the same surface, and both listeners share one mount baseline and one return-to-tab refresh.
|
| 646 |
+
*
|
| 647 |
+
* ⛔ IT NEVER POLLS. Own writes update the current tab from their response; a change made elsewhere
|
| 648 |
+
* is checked when the person returns to this tab or focuses its window. A forgotten tab left open
|
| 649 |
+
* overnight creates no database traffic.
|
|
|
|
|
|
|
|
|
|
| 650 |
*/
|
| 651 |
export function subscribeChanges(scope: string,
|
| 652 |
onChange: (changed: string[]) => void): () => void {
|
| 653 |
if (typeof window === "undefined") return () => {};
|
| 654 |
let watch = changeWatches.get(scope);
|
| 655 |
if (!watch) {
|
| 656 |
+
const w0: ChangeWatch = {
|
| 657 |
+
listeners: new Set(), tokens: null, inFlight: false,
|
| 658 |
+
refresh: async () => {
|
| 659 |
+
if (w0.inFlight || document.visibilityState !== "visible") return;
|
| 660 |
+
w0.inFlight = true;
|
| 661 |
+
try {
|
| 662 |
+
const next = await fetchChangeTokens(scope);
|
| 663 |
+
const changed = changedBuckets(w0.tokens, next);
|
| 664 |
+
if (next) w0.tokens = next;
|
| 665 |
+
if (changed.length)
|
| 666 |
+
for (const listener of [...w0.listeners]) listener(changed);
|
| 667 |
+
} finally {
|
| 668 |
+
w0.inFlight = false;
|
| 669 |
+
}
|
| 670 |
+
},
|
| 671 |
+
};
|
|
|
|
| 672 |
watch = w0;
|
| 673 |
changeWatches.set(scope, w0);
|
| 674 |
}
|
| 675 |
+
const w = watch;
|
| 676 |
+
w.listeners.add(onChange);
|
| 677 |
+
|
| 678 |
+
// One tiny revision baseline after the necessary initial data load lets later user returns
|
| 679 |
+
// preserve the browser cache when nothing changed. It is never an interval.
|
| 680 |
+
if (w.tokens === null && !w.inFlight) void w.refresh();
|
| 681 |
+
|
| 682 |
+
if (!visibilityBound) {
|
| 683 |
+
visibilityBound = true;
|
| 684 |
+
window.addEventListener("visibilitychange", refreshVisibleChanges);
|
| 685 |
+
window.addEventListener("focus", refreshVisibleChanges);
|
| 686 |
+
}
|
|
|
|
|
|
|
|
|
|
| 687 |
|
| 688 |
return () => {
|
| 689 |
w.listeners.delete(onChange);
|
| 690 |
+
if (w.listeners.size === 0) {
|
|
|
|
|
|
|
| 691 |
// ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than
|
| 692 |
+
// compare against a token from before it was unmounted.
|
| 693 |
+
changeWatches.delete(scope);
|
|
|
|
|
|
|
| 694 |
}
|
| 695 |
};
|
| 696 |
}
|
|
|
|
| 870 |
* and an unreachable server — and a create pane that explodes because a picker could not fetch
|
| 871 |
* is worse than one that says it has nothing to offer.
|
| 872 |
*/
|
| 873 |
+
export async function fetchLinkTargets(): Promise<LinkTarget[]> {
|
| 874 |
+
try {
|
| 875 |
+
// A link picker uses only key/label/schema. `brief=1` keeps the server's table wall but does
|
| 876 |
+
// not transfer row counts or the row-bearing `user_tables` document for this one field menu.
|
| 877 |
+
const res = await fetch(`${API_V1}/tables?brief=1`, { credentials: CREDENTIALS });
|
| 878 |
if (handledSession(res) || !res.ok) return [];
|
| 879 |
const body = (await readJson(res)) as { tables?: LinkTarget[] } | null;
|
| 880 |
return Array.isArray(body?.tables) ? body.tables : [];
|
web/src/customer-grid/iconShapes.ts
CHANGED
|
@@ -350,13 +350,24 @@ export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
|
|
| 350 |
* the two ends, so the name matters more than the word: a tone the host does not recognise
|
| 351 |
* degrades to the default and the user's choice silently disappears on reload.
|
| 352 |
*/
|
| 353 |
-
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
|
| 354 |
-
neutral: { fill: LP_LINE, stroke: LP_MUTED },
|
| 355 |
-
blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
|
| 356 |
-
green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
|
| 357 |
-
yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
|
| 358 |
-
red: { fill: LP_RED, stroke: LP_RED_DEEP },
|
| 359 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
|
| 362 |
neutral: "Neutral",
|
|
|
|
| 350 |
* the two ends, so the name matters more than the word: a tone the host does not recognise
|
| 351 |
* degrades to the default and the user's choice silently disappears on reload.
|
| 352 |
*/
|
| 353 |
+
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
|
| 354 |
+
neutral: { fill: LP_LINE, stroke: LP_MUTED },
|
| 355 |
+
blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
|
| 356 |
+
green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
|
| 357 |
+
yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
|
| 358 |
+
red: { fill: LP_RED, stroke: LP_RED_DEEP },
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
/**
|
| 362 |
+
* The tone is persisted data, not a compile-time fact. Older hosts used `grey` for the neutral
|
| 363 |
+
* tone, and a stale workspace can still carry it after the palette was renamed. Every painter
|
| 364 |
+
* must degrade to the neutral ink rather than dereferencing an absent paint record and taking the
|
| 365 |
+
* whole React tree down while drawing an otherwise healthy database.
|
| 366 |
+
*/
|
| 367 |
+
export function folderTonePaint(tone: unknown): { fill: string; stroke: string } {
|
| 368 |
+
return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
|
| 369 |
+
?? FOLDER_TONE_PAINT.neutral;
|
| 370 |
+
}
|
| 371 |
|
| 372 |
export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
|
| 373 |
neutral: "Neutral",
|
web/src/customer-grid/icons.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
| 22 |
CHART_KIND_SHAPES,
|
| 23 |
CHART_KIND_TONE,
|
| 24 |
FOLDER_SHAPE_PATHS,
|
| 25 |
-
|
| 26 |
MODE_SHAPES,
|
| 27 |
TYPE_SHAPES,
|
| 28 |
} from "./iconShapes";
|
|
@@ -130,7 +130,7 @@ export function ToneModeIcon({
|
|
| 130 |
tone: FolderTone;
|
| 131 |
size?: number;
|
| 132 |
}) {
|
| 133 |
-
const paint =
|
| 134 |
return (
|
| 135 |
<svg
|
| 136 |
className="cg-mode-icon cg-tone-icon"
|
|
@@ -178,7 +178,7 @@ export function ChartKindIcon({
|
|
| 178 |
kind: ChartKindKey;
|
| 179 |
size?: number;
|
| 180 |
}) {
|
| 181 |
-
const paint =
|
| 182 |
return (
|
| 183 |
<svg
|
| 184 |
className="cg-mode-icon cg-tone-icon"
|
|
@@ -483,7 +483,7 @@ export function FolderMark({
|
|
| 483 |
size?: number;
|
| 484 |
}) {
|
| 485 |
const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE;
|
| 486 |
-
const paint =
|
| 487 |
return (
|
| 488 |
<svg
|
| 489 |
className="cg-folder-mark"
|
|
|
|
| 22 |
CHART_KIND_SHAPES,
|
| 23 |
CHART_KIND_TONE,
|
| 24 |
FOLDER_SHAPE_PATHS,
|
| 25 |
+
folderTonePaint,
|
| 26 |
MODE_SHAPES,
|
| 27 |
TYPE_SHAPES,
|
| 28 |
} from "./iconShapes";
|
|
|
|
| 130 |
tone: FolderTone;
|
| 131 |
size?: number;
|
| 132 |
}) {
|
| 133 |
+
const paint = folderTonePaint(tone);
|
| 134 |
return (
|
| 135 |
<svg
|
| 136 |
className="cg-mode-icon cg-tone-icon"
|
|
|
|
| 178 |
kind: ChartKindKey;
|
| 179 |
size?: number;
|
| 180 |
}) {
|
| 181 |
+
const paint = folderTonePaint(CHART_KIND_TONE[kind]);
|
| 182 |
return (
|
| 183 |
<svg
|
| 184 |
className="cg-mode-icon cg-tone-icon"
|
|
|
|
| 483 |
size?: number;
|
| 484 |
}) {
|
| 485 |
const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE;
|
| 486 |
+
const paint = folderTonePaint(icon?.tone ?? DEFAULT_FOLDER_TONE);
|
| 487 |
return (
|
| 488 |
<svg
|
| 489 |
className="cg-folder-mark"
|
web/src/settings/AdminPane.tsx
CHANGED
|
@@ -200,10 +200,10 @@ export function AdminPane({ user }: { user: { username: string; name: string; ro
|
|
| 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
|
| 205 |
-
value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "—"}
|
| 206 |
-
hint={fleet?.cost.basis}
|
| 207 |
/>
|
| 208 |
</div>
|
| 209 |
{ov.totals.unknownTenants > 0 ? (
|
|
@@ -329,8 +329,9 @@ export function AdminPane({ user }: { user: { username: string; name: string; ro
|
|
| 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}
|
| 333 |
-
{fleet.cost.
|
|
|
|
| 334 |
? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
|
| 335 |
: ""}
|
| 336 |
{fleet.cost.unknownCadence
|
|
|
|
| 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 floor"
|
| 205 |
+
value={fleet ? (fleet.cost.usd === 0 ? "$0.00 idle" : `~$${fleet.cost.usd.toFixed(2)}`) : "—"}
|
| 206 |
+
hint={fleet?.cost.basis}
|
| 207 |
/>
|
| 208 |
</div>
|
| 209 |
{ov.totals.unknownTenants > 0 ? (
|
|
|
|
| 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} recurring of {fleet.rows.length}
|
| 333 |
+
{`, ${fleet.cost.nativeSchedules} exact schedule${fleet.cost.nativeSchedules === 1 ? "" : "s"}`}
|
| 334 |
+
{fleet.cost.fleetRunsPerDay
|
| 335 |
? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
|
| 336 |
: ""}
|
| 337 |
{fleet.cost.unknownCadence
|
web/src/settings/platformAdminApi.ts
CHANGED
|
@@ -136,6 +136,8 @@ export interface AutomationRow {
|
|
| 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;
|
|
@@ -146,11 +148,11 @@ export interface AutomationRow {
|
|
| 146 |
}
|
| 147 |
|
| 148 |
export interface FleetCost {
|
|
|
|
| 149 |
cadence: string;
|
|
|
|
| 150 |
invocationsPerMonth: number;
|
| 151 |
-
|
| 152 |
-
freeSchedulerPct: number;
|
| 153 |
-
lambdaMb: number;
|
| 154 |
usd: number;
|
| 155 |
fleetRunsPerDay: number;
|
| 156 |
unknownCadence: number;
|
|
@@ -214,6 +216,8 @@ export function getAutomations(
|
|
| 214 |
cost: FleetCost;
|
| 215 |
errors: Record<string, string>;
|
| 216 |
tickEnabled: boolean;
|
|
|
|
|
|
|
| 217 |
}>
|
| 218 |
> {
|
| 219 |
return call(`/automations${q(tenant)}`);
|
|
|
|
| 136 |
cron: string;
|
| 137 |
/** null = a cadence the server would not parse; shown as "custom", never as a number. */
|
| 138 |
runsPerDay: number | null;
|
| 139 |
+
nativeSchedules: number | null;
|
| 140 |
+
scheduleModes: string[];
|
| 141 |
state: string;
|
| 142 |
lastRunAt: string;
|
| 143 |
lastSummary: string;
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
export interface FleetCost {
|
| 151 |
+
mode: "exact-schedules-fargate";
|
| 152 |
cadence: string;
|
| 153 |
+
nativeSchedules: number;
|
| 154 |
invocationsPerMonth: number;
|
| 155 |
+
perRunMinimumUsd: number;
|
|
|
|
|
|
|
| 156 |
usd: number;
|
| 157 |
fleetRunsPerDay: number;
|
| 158 |
unknownCadence: number;
|
|
|
|
| 216 |
cost: FleetCost;
|
| 217 |
errors: Record<string, string>;
|
| 218 |
tickEnabled: boolean;
|
| 219 |
+
controlEnabled: boolean;
|
| 220 |
+
executionMode: "exact-aws" | "local-disabled";
|
| 221 |
}>
|
| 222 |
> {
|
| 223 |
return call(`/automations${q(tenant)}`);
|