Markdown Dashboards (datachart blocks)
Status: partially implemented (2026-06-10) — inline/frontmatter/url work; see "Implementation status & remaining tasks" below.
Render charts directly from a fenced ```datachart code block in a note. trip2g
fetches the chart's data from an HTTP-JSON endpoint, caches the result in SQLite
keyed by the note version, and the published page draws it with ECharts. No
admin UI, no drag-and-drop in the MVP — charts render inline, in document order,
exactly like any other fenced block renders today.
Implementation status & remaining tasks (2026-06-10)
Done & live:
- Parsing:
extractCharts→ typeddata:{source}model (internal/model/note_chart.go). - Renderer:
chartRendereremits<div class="chart">+<script class="chart__data">; non-datachart code blocks unchanged (internal/mdloader/chart_renderer.go). - Sources end-to-end: inline (baked), frontmatter (vault
[[link]]→ presigned URL → browser fetch; assets marked infindAssets), url (server-side fetch + cache). - Cache:
chart_data_cachetable + sqlcGetChartData/UpsertChartData. - Orchestration:
internal/chartdataservice (embedded inapp),refresh_chart_databackjob (internal/case/backjob/refreshchartdata/), live-update via debounced re-render. - Widget:
assets/chart/(lazy-loads echarts), injected viaUserJSURLs. - Local demo: mock adapter
scripts/data_mock_server.py+data_mockcompose service. - User docs:
docs/{en,ru}/user/chartdata.md.
Task A — Scheduled refresh (chart_ttl cron) — NOT DONE
Problem. chart_ttl is documented but not enforced. fetched_at is written to chart_data_cache but never compared to a TTL, and there is no cron. So url/internal data is fetched once (at note load, on a cache miss) and never refreshes on a schedule — only when the note's version_id changes (edit). Wrong for live metrics.
Already correct: the initial fetch happens at render time (noteloader.Load → chartRenderer.renderChart → chartdata.ChartRows → cache miss → EnqueueChartDataRefresh → job → cache → debounced reload), not on reader request (the page serves cached HTML).
Build:
- Cronjob
internal/case/cronjob/refreshstalecharts/{job.go,resolve.go}(pattern:internal/case/cronjob/cleanupapikeylogs/), ~every minute; register ininternal/cronjobs/jobs.go. - Each run: iterate the loaded notes' url/internal charts (
NoteView.Charts+ the note'sRawMeta["chart_ttl"]). For each: read the cache row'sfetched_at, parsechart_ttl(Go duration; absent = no auto-refresh), and ifnow - fetched_at > ttl→EnqueueChartDataRefresh. Job caches →reloadLoopre-renders. - Put the staleness logic in
chartdata(e.g.func (c *ChartData) RefreshStale(...)), don't duplicate in the cron. Extendchartdata.Envso it can readfetched_at(todayCachedChartDatareturns only the data string). Add a compile-time check. - Per-chart TTL: read
RawMeta["chart_ttl"]per note, or attach the parsed TTL to eachNoteViewChartinextractCharts.
Accept: url chart with chart_ttl: 1m re-fetches ~every minute (new fetched_at); without it, no scheduled refresh.
Task B — internal SQL source — NOT DONE
Problem. data.source = "internal" renders a loader only. chartdata.ChartRows enqueues a refresh only for ChartSourceURL; the job only does HTTP. Full design is the Internal SQL source section below — implement it.
Build (per that section):
note_version_frontmatters(version_id, data json)— persistRawMetaas JSON at push/sync (queryable via SQLite JSON1; shared with Obsidian Bases).internal_sql_tables(table_name, min_sync_interval_secs, synced_at)— admin-managed allowlist + cadence.internal/internalsql/— non-durable filtered read replica: lazy per-table sync (ATTACH main read-only→CREATE TABLE AS SELECT), debounced bysynced_at. Security by construction (only allowlisted tables exist).- Internal fetch path: run
data.sqlagainst the replica (SELECT-only +LIMIT+ timeout), cache rows. - Extend
chartdata.ChartRowsmiss-enqueue to coverChartSourceInternal. - Admin CRUD for
internal_sql_tables.
Accept: internal chart renders notes-by-status; queries can't reach non-allowlisted tables (e.g. secrets).
Task C — Obsidian in-editor preview — NOT DONE
Why. The fenced ```datachart block was chosen over a <DataChart />-style
component precisely because a fenced block is Obsidian's native extension point
(registerMarkdownCodeBlockProcessor). That payoff is only realized once the
plugin actually renders it: authors should see the chart in Obsidian —
WYSIWYG, identical to the published page — instead of staring at raw JSON. This
closes the loop (write in Obsidian → preview in Obsidian → publish unchanged) and
turns the editor into the test harness for charts.
Build (component #9 below):
- In
obsidian-sync/src/main.ts, register
registerMarkdownCodeBlockProcessor("datachart", …). - Parse the block's JSON, then reuse the shared widget from
assets/chart/
so in-editor rendering is byte-for-byte the site's — don't re-implement ECharts
drawing in the plugin. - Resolve
data.sourcein-editor:inline→ draw immediately fromrows.frontmatter→ resolve the[[link]]against the vault and read the local
file (.csv/.json) directly — no presigned URL needed inside Obsidian.url/internal→ fetch viarequestUrlif reachable, else draw a
placeholder ("server-fetched on publish"); these depend on trip2g's cache/
replica, which the plugin can't fully reproduce.
- (Optional) a
.datachart.jsonfile view for standalone preview of data assets.
Accept: opening a note with a ```datachart block in Obsidian shows the
rendered chart (at least for inline/frontmatter), matching the published page;
no separate "view on site" round-trip needed to check a chart.
Authoring format
```datachart
{
"title": "Daily revenue",
"data": {
"source": "url",
"url": "http://clickhouse-adapter:8123/query",
"body": "SELECT day, revenue FROM stats ORDER BY day"
},
"config": {
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [{ "type": "line", "encode": { "x": "day", "y": "revenue" } }]
}
}
```
| Field | Meaning |
|---|---|
data |
Typed data source — { "source": "url" | "frontmatter" | "internal" | "inline", … }. See Data sources. The url body/SQL is server-side only — never shipped to the browser. |
config |
An ECharts option object. Fetched rows are auto-injected into config.dataset.source (no placeholder needed; "$data" optional for custom placement). |
Per-note frontmatter:
chart_ttl: 1h # optional; refresh cadence (Go duration). Absent = refresh only on version change.
charts: custom # optional; suppress inline rendering, place charts via ctx.Charts() in a custom template.
Data sources
A chart is always a ```datachart block: config in the block, and a typed
data object that says where the rows come from. One syntax, one widget; the
data.source discriminator selects the path.
data.source |
Shape | Data origin | Who fetches |
|---|---|---|---|
frontmatter |
{source, ref} |
a frontmatter key holding a vault [[link]] → asset |
client (resolve link → URL → fetch) |
url |
{source, url, body?} |
external HTTP-JSON endpoint | backend (fetch + cache + secrets) |
internal |
{source, sql} |
trip2g's own content (filtered replica) | backend (replica + cache) |
inline |
{source, rows} |
bundled in the block | none |
Data injection. By default trip2g/the widget injects the fetched rows into
config.dataset.source automatically — you do not write a placeholder. If
config has no dataset, one is added as { "source": <rows> }; the series'
encode then references the row columns. The rows may be an array-of-objects or
a 2D array (both are valid ECharts dataset.source).
"$data" is optional: write it explicitly anywhere in config only when you
need the rows somewhere other than dataset.source — trip2g replaces every
"$data" it finds. config stays a plain ECharts option object either way (the
trip2g-specific "where from" lives in data, never mixed into config).
frontmatter source — data from a vault file (client-fetched)
The data file is referenced from frontmatter, not inside the code block,
because Obsidian gives frontmatter [[links]] real link support — highlighting,
autocomplete, graph edges — which a link buried in a ```code fence does not
(verified in Obsidian). The block just names the key:
---
chart_sales: "[[sales.datachart.csv]]" # Obsidian-native link property
---
At render trip2g reads RawMeta["chart_sales"], extracts the wikilink target,
resolves it to the asset's presigned URL via currentPage.AssetReplaces[target]
(the same ResolveWikilink mechanism, internal/mdloader/link_resolver.go:33),
and emits <div class="chart" data-src="<url>" data-format="csv|json"> + the
inline config. The browser fetches the data — no server-side read, no
GetAssetContent, no memory/streaming concerns (a .csv/.json is served like
any other asset, gated by the same presigned URL as the note's images).
.jsonreferenced file → array used directly asdataset.source..csv→ the widget parses it (header row + numeric coercion → 2D source);
chart type comes from the block'sconfig.series[].type.- Dependency tracking: the asset must be marked as a note dependency so it
gets a presigned URL. Body[[links]]are marked byfindAssets; frontmatter
links need an explicit mark (open item — verify whether frontmatter wikilinks
are already registered as asset deps; if not, add it duringextractCharts).
Dropped: the standalone
![[x.datachart.json]]embed and the server-side
GetAssetContentread. Superseded bydata.source = "frontmatter"+
client-fetch — simpler, Obsidian-friendly, and avoids reading asset bytes
through the app.
Data adapter contract
dataUrl may be any HTTP-JSON endpoint. The reference adapter is the sibling
project trip2g_agent_queue (POST /v1/query, internal/api/handlers.go:568):
- Request body:
{"sql": "SELECT ..."}(SELECT-only; non-SELECT →400). - Response: a flat JSON array of row objects (
[]map[string]any), e.g.
[{"day":"2026-06-01","revenue":1234}, ...]; empty →[]; errors →
{"error":"..."}.
This array maps directly onto an ECharts dataset.source — no transform
layer. The fetcher auto-injects the fetched array into config.dataset.source
(no placeholder needed). data.body is sent verbatim as the POST body (for this
adapter it is the JSON {"sql":...}, not raw SQL).
Auth: secrets as request headers
Some endpoints need an auth header. Reuse the existing secrets subsystem
rather than inventing storage — it already provides encrypted storage,
prefix-namespacing, CRUD, and an admin widget.
Naming — follow the established SecretPrefix convention
(internal/model/secret_prefix.go, format <entity>:<id>:). For charts, key by
domain:
chart_secret:{domain}:{HEADERNAME} → value = header value
e.g. chart_secret:trip2g_agent_queue:Authorization → "Bearer …"
chart_secret:api.example.com:X-Api-Key → "…"
Add ChartSecretPrefix(domain string) SecretPrefix to secret_prefix.go
returning chart_secret:{domain}:. Its .Like() lists all headers for a domain.
Fetch-time (in the refresh_chart_data backjob, before the HTTP request):
- Extract the host from
dataUrl. ListSecretKeys("chart_secret:{host}:%")→ for each key, decrypt the value.- Set HTTP header
HEADERNAME: valuefor each.
Admin UI — the secrets widget is already prefix-driven and reusable:
$trip2g_admin_secrets (assets/ui/admin/secrets/secrets.view.tree) exposes a
key_prefix property; changewebhook/show embeds it via secrets_prefix()
(assets/ui/admin/changewebhook/show/show.view.ts:165). New admin page:
- List domains — extracted from
chart_secret:*keys (ListSecretKeys,
split on:), optionally unioned with domains discovered in note chart blocks. - For each domain, embed
$trip2g_admin_secretswith
key_prefix = "chart_secret:{domain}:"— gives add/edit/delete of header
secrets per domain with zero new widget code.
Secrets are server-side only; like dataBody, they never reach the browser.
Internal SQL source (content dashboards)
A datachart may target trip2g's own data instead of an HTTP endpoint —
turning the platform into a Grafana for its own content: notes by status,
signups over time, most-read pages, Telegram delivery stats. The chart declares
"dataSource": "internal" and a dataSql SELECT instead of dataUrl.
Security by construction: a filtered read replica
Raw SELECT against the main DB is unacceptable — it holds secrets, users,
payment data for the whole instance. Instead of sandboxing queries (SQL parsing,
view whitelists — fragile), isolate by data: internal SQL runs against a
separate replica SQLite file that contains only allowlisted tables. A query
physically cannot reach secrets because that table isn't in the replica.
The allowlist lives in a DB table (admin-manageable at runtime, no env/restart):
CREATE TABLE internal_sql_tables (
table_name text primary key,
min_sync_interval_secs integer not null default 300,
synced_at integer not null default 0, -- unix secs, last replica sync
created_at datetime not null default current_timestamp
);
Empty table → nothing queryable → feature off. No separate enable flag needed:
the rows are both the allowlist and the on-switch.
Lazy sync (no timer — rides the chart refresh)
The replica is rebuilt inside the refresh_chart_data job, just before an
internal query, debounced per table by synced_at:
for row in internal_sql_tables:
if now - row.synced_at > row.min_sync_interval_secs:
BEGIN;
replica: DROP TABLE IF EXISTS t; CREATE TABLE t AS SELECT * FROM main.t; -- main ATTACHed read-only
COMMIT; -- atomic swap, no torn reads
row.synced_at = now
- Demand-driven: tables sync only when an internal chart actually needs them;
a burst of refreshes shares one sync (per-tablesynced_atguard). - Full copy is fine at target scale (10–50k rows):
CREATE TABLE AS SELECT
≈ 10–50 ms, auto-handles schema drift. Reconsider incremental only at millions
of rows / hundreds of MB / seconds-scale cadence. - Replica is disposable → open it non-durable for speed:
PRAGMA journal_mode=MEMORY; PRAGMA synchronous=OFF;(rebuildable from main). - Isolation bonus: heavy
GROUP BYruns on the replica, never locking the
main write DB — the same R/W-split principle asdocs/dev/sqlite.md, one step
further.
Mixed freshness: a chart joining two tables with different intervals sees one
fresher than the other (fine for dashboards; align intervals if a chart needs
cross-table consistency).
Frontmatter index — the content substrate
To chart over note metadata (status, tags, category), frontmatter must be
queryable, but today note_versions stores only raw content; frontmatter
is parsed in-memory only. Persist it:
CREATE TABLE note_version_frontmatters (
version_id integer primary key references note_versions(id) on delete cascade,
data text not null -- RawMeta serialized as JSON
);
Populated at push/sync (where the YAML is already parsed). Query with SQLite
JSON1: json_extract(data,'$.status'), json_each(data,'$.tags'). Add
note_version_frontmatters to internal_sql_tables and dashboards over content
work:
SELECT json_extract(f.data,'$.status') AS status, count(*) AS n
FROM note_version_frontmatters f
JOIN <live-version filter>
GROUP BY status
This index is shared with Obsidian Bases (docs/dev/obsidian_bases.md), which
also needs a queryable frontmatter index — build it once, both features win.
Config
| Setting | Where | Default |
|---|---|---|
| allowed tables + per-table cadence | internal_sql_tables rows (admin CRUD) |
empty (off) |
| replica file path | internal/appconfig/config.go |
e.g. internal_sql.sqlite next to main DB |
Permission model
Access to the note is access to all data on it. The fetched data is embedded
in the note's rendered HTML, and that HTML is only ever served to a viewer who
already passed the note's permission gate (guest / paid / admin). A guest viewing
a paid note never receives the HTML, therefore never the data, therefore never
the SQL. Different audiences → different pages. No per-block gating, no separate
data endpoint.
Data flow
NOTE (markdown) SERVER (trip2g) BROWSER
────────────── ─────────────── ───────
```datachart parse: extractCharts() render: chart_data_cache lookup
{ dataUrl, ──▶ walk AST, find lang==chart (version_id, chart_hash)
dataBody, hash = sha256(url\0body) │
config } → NoteView.Charts[] ┌─────┴─────┐
``` hit miss / stale
│ │
embed config+data │ enqueue refresh_chart_data
into note HTML ◀─────┘ (goqite backjob)
│ │
▼ HTTP fetch dataUrl/dataBody
<div class="chart" data-i=0> → write cache row
<script class="chart__data" → publish noteChange (update)
type="application/json"> │
{config, data|null}</script> ▼
│ SSE noteChanges → $trip2g_user_live
▼ (if live-reload toggle on) → reload
ECharts IIFE widget draws;
data===null → loader
Cache model
New table, keyed exactly by note version as requested:
-- db/migrations/<ts>_create_chart_data_cache.sql (via: make db-new name=create_chart_data_cache)
-- migrate:up
CREATE TABLE chart_data_cache (
version_id integer not null,
chart_hash text not null,
data_json text not null,
fetched_at integer not null, -- unix seconds
primary key (version_id, chart_hash)
);
-- migrate:down
DROP TABLE IF EXISTS chart_data_cache;
chart_hash = sha256(dataUrl + "\x00" + dataBody)— identical query under the
same version reuses the row.- Keying by
version_idgives free invalidation: a new note version is a new
key, so it starts empty (→ loader) until the refresh job fills it. No
copy-forward.
Render-time semantics
Look up (version_id, chart_hash):
| State | Embedded data |
Loader? | Side effect |
|---|---|---|---|
| No row | null |
yes | (refresh job already enqueued on version change) |
Fresh (now - fetched_at ≤ ttl) |
data | no | none |
Stale (now - fetched_at > ttl) |
old data | no | enqueue refresh_chart_data |
Refresh is triggered by exactly two events: a version bump (job enqueued at
publish time) and a render that observes staleness. Nobody views the page →
nothing refetches. There is no polling cron for freshness.
No feedback loop: the job writes fetched_at = now before publishing the
noteChange. The triggered reload then sees a fresh row and does not re-enqueue.
This ordering is covered by a test.
Components & file touch-points
| # | Piece | Location | Convention to mirror |
|---|---|---|---|
| 1 | NoteViewChart type + Charts []NoteViewChart field + extractCharts() |
internal/model/note.go |
Headings field (:213) and extractHeadingsAndGenerateIDs() (:815); called from ExtractMetaData() (:541). Block-walking mirrors extractJsonnetBlocks() in internal/mdloader/vault_patch.go:53. Read chart_ttl / charts frontmatter via the existing extract* helpers. |
| 2 | chart_data_cache table + read/write queries |
db/migrations/, internal/db/queries.read.sql, internal/db/queries.write.sql |
make db-new name=... (dbmate) then make sqlc. Table style: db/migrations/20260526120000_create_secrets.sql. |
| 3 | refresh_chart_data backjob (resolve domain secrets → HTTP-JSON fetch with headers → inject array into config.dataset.source → cache write → noteChange publish) |
internal/case/backjob/refreshchartdata/{job.go,resolve.go} |
internal/case/backjob/sendformsubmit/ — const JobID, QueueID = model.BackgroundDefaultQueue, jobs.Register(env, QueueID, JobID, Priority, Resolve), Enqueue* method, Params struct + Resolve. Register import in cmd/server/main.go (alongside :47). |
| 3b | ChartSecretPrefix(domain) helper + domain-header lookup |
internal/model/secret_prefix.go |
existing ChangeWebhookSecretPrefix etc.; read via ListSecretKeys(prefix.Like()) + decrypt. |
| 6b | Admin secrets-by-domain page | assets/ui/admin/chartsecrets/ + GraphQL list-domains |
reuse $trip2g_admin_secrets widget with key_prefix="chart_secret:{domain}:", as changewebhook/show does (show.view.ts:165). |
| 4 | Cron cleanup of stale-version rows | internal/case/cronjob/cleanupchartdatacache/job.go |
internal/case/cronjob/cleanupapikeylogs/job.go — Name(), Schedule() cron string, ExecuteAfterStart(), Execute()→Resolve(). Register in internal/cronjobs/jobs.go. |
| 5 | Goldmark node renderer for datachart blocks (inline <div class="chart"> + embedded JSON; suppressed when charts: custom) |
internal/mdloader/loader.go:112-119 |
register via renderer.WithNodeRenderers(util.Prioritized(&chartRenderer{}, <priority>)) next to linkRenderer/imageRenderer/headingRenderer. |
| 5b | Embed renderer for ![[*.datachart.json]] (read NoteAsset bytes → emit chart container + bundled JSON; no fetch/cache) |
internal/mdloader/loader.go:114 (image/embed renderer) |
suffix-detect .datachart.json; read via NoteAssetByAbsolutePathAndSha256Hash/NoteAssetByID. |
| 6 | ctx.Charts() helper returning ctx.Note.Charts |
internal/defaulttemplate/template.go |
existing Ctx methods (e.g. ContentRefs() :212). |
| 7 | ECharts client widget (vanilla IIFE) | assets/chart/src/index.ts + assets/chart/esbuild.browser.mjs + package.json script chart → assets/chart.js; injected via UserJSURLs() in cmd/server/main.go:999 |
assets/toc/ (esbuild IIFE reading embedded application/json). ECharts loaded with $mol_import.script-style async or bundled. |
| 9 | Obsidian plugin preview — datachart code-block processor (+ .datachart.json view) for WYSIWYG in-editor, identical to the site |
obsidian-sync/src/ (main.ts entry) |
registerMarkdownCodeBlockProcessor("datachart", …), fetch via requestUrl, render with the shared ECharts widget from assets/chart/. |
| 10 | note_version_frontmatters table + populate at push/sync (RawMeta → JSON) |
db/migrations/, internal/case/pushnotes/ or noteloader |
serialize NoteView.RawMeta; shared with Obsidian Bases (obsidian_bases.md). |
| 11 | internal_sql_tables table + non-durable read replica + lazy per-table sync |
db/migrations/, internal/internalsql/ (new pkg), internal/appconfig/config.go |
open replica via dbmate/sqlite ConnectionString (internal/dbmate/sqlite/sqlite.go:76); ATTACH main read-only; CREATE TABLE AS SELECT per table in one txn. |
| 11b | internal data source in the fetch job (run dataSql on replica after ensuring fresh) |
internal/case/backjob/refreshchartdata/ |
SELECT-only guard + LIMIT cap + timeout; same cache/TTL/notify as HTTP source. |
| 11c | Admin CRUD for internal_sql_tables |
assets/ui/admin/internalsqltables/ |
reuse admin CRUD patterns (docs/dev/frontend_crud.md); show synced_at per row. |
Cache cleanup (cron)
Delete chart_data_cache rows whose version_id is no longer the live version
of any note. Daily schedule (e.g. "0 0 2 * * *"). Exact "live version" join to
be finalized against the note-version source of truth during implementation
(detail flagged, not blocking).
Live update
No new infrastructure — reuse the existing noteChanges GraphQL SSE subscription
and notebus fan-out (docs/dev/obsidian_sse_pulls.md; resolver
internal/graph/* NoteChanges :289). The public page already subscribes via
$trip2g_user_live (assets/ui/user/live/live.view.ts:4), gated by the existing
opt-in toggle $trip2g_user_live_reload_toggler (persisted in $mol_state_local
as trip2g_live_reload). Step 3's job publishing a noteChange{update} for the
note path is the entire wiring; the page reloads and re-reads the fresh cache.
Implementation order (TDD, red → green)
- Model / extraction —
extractCharts()findsdatachartblocks, computes the
hash, parsesconfig/dataUrl/dataBody; readschart_ttl/charts
frontmatter. Tests first (table-driven,testify/require). - Cache — migration + sqlc read/write queries; thin store with get/upsert.
- Backjob —
refresh_chart_data: resolvechart_secret:{host}:*headers →
fetch → inject response array intoconfig.dataset.source→ upsert
(fetched_at=now) → publishnoteChange. Test the write-before-notify
ordering and the header attachment. - Cron cleanup — delete non-live-version rows.
- Renderer — inline
<div class="chart">+ embedded{config, data};
suppressed undercharts: custom. Render totemplate.HTML.
5b. Embed renderer —![[*.datachart.json]]reads the asset's bundled
{settings, data}and renders a chart inline (no fetch/cache/job). ctx.Charts()helper.- ECharts widget —
assets/chart/, build script,UserJSURLs()injection;
data===null→ loader; redraw on live reload. - Admin secrets-by-domain page — list domains + embed
$trip2g_admin_secrets
per domain (chart_secret:{domain}:prefix). - Obsidian plugin preview —
datachartcode-block processor +.datachart.json
handler inobsidian-sync, reusing the shared widget for in-editor WYSIWYG.
Internal-source track (optional, can land independently)
- Frontmatter index —
note_version_frontmatterstable + populate at
push/sync (RawMeta → JSON). Unlocks content queries; shared with Bases. - Filtered replica —
internal_sql_tablestable + non-durable replica +
lazy per-table sync (internal/internalsql/). Tests first: allowlist
enforcement, atomic swap, debounce bysynced_at. internaldata source — wiredataSource:"internal"+dataSqlinto the
refresh job (SELECT-only guard, LIMIT, timeout); same cache/TTL/notify.- Admin CRUD for
internal_sql_tables(withsynced_atdisplay).
Acceptance criteria
- A note with one
```datachartblock renders an<div class="chart">+ a sibling
application/jsonscript containingconfiganddata(ordata:nullon
cache miss);dataUrl/dataBodyare absent from the HTML. - First render of a new version embeds
data:null; after therefresh_chart_data
job completes, a fresh render embeds the fetched data. - Editing the note (new
version_id) makes the chart re-fetch on next view. - With
chart_ttl: 1h, a render past TTL serves stale data and enqueues a
refresh; a render within TTL enqueues nothing. - The refresh job writes
fetched_atbefore publishingnoteChange(no
re-enqueue loop) — asserted by test. charts: customsuppresses inline output;ctx.Charts()returns the parsed
charts for a custom template.- Cron cleanup removes rows for versions that are no longer live.
- ECharts widget draws the chart from embedded JSON and shows a loader when
datais null. - A
chart_secret:{host}:Authorizationsecret is sent as theAuthorization
header on the fetch for that host; secrets never appear in the page HTML. - The admin page lists domains from
chart_secret:*keys and edits per-domain
header secrets via the reused$trip2g_admin_secretswidget.
Risks & mitigations
| Risk | Mitigation |
|---|---|
| Render blocking on external HTTP | Render never fetches; it only reads SQLite. All fetching is async via the goqite backjob. |
| Untrusted upstream (SSRF, huge payloads) | Allowlist/limit dataUrl hosts; cap response size and apply a fetch timeout in the backjob. Flag for review. |
| Cache growth across versions | Daily cron deletes rows for non-live versions. |
| Refresh feedback loop via live reload | Write fetched_at before noteChange; tested. |
| ECharts bundle size on public pages | Ship a focused esbuild build; load async so it doesn't block first paint. |
Out of scope (MVP)
- Admin drag-and-drop / reconfigure (would reuse
$mol_dragfrom
assets/ui/admin/layout/editor/, persisting layout back into the note). - Per-viewer layouts.
- Charting against trip2g's own SQLite or per-block permissions.