diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4444cdd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: Erlang CI +on: + pull_request: + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + push: + branches: [master] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' +permissions: + contents: write + pull-requests: write +jobs: + ci: + uses: Taure/erlang-ci/.github/workflows/ci.yml@v2.1.1 + with: + otp-version: '29' + enable-ex-doc: true + # fmt/xref/dialyzer/eunit/summary are on by default. audit + sbom are left + # off (their defaults): with this repo's `{vsn, git}` deps, rebar3_sbom + # badarg's on the `git` atom version and rebar3_audit crashes formatting a + # LOW-advisory glyph under OTP 29. Re-enable once the plugins handle it. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c73d3ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,9 @@ +name: Release +on: + push: + tags: ['v*'] +permissions: + contents: write +jobs: + release: + uses: Taure/erlang-ci/.github/workflows/release.yml@v2.1.1 diff --git a/.gitignore b/.gitignore index 69fa449..6a53e20 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ _build/ +doc/ +*.crashdump +erl_crash.dump diff --git a/README.md b/README.md index c543a8b..5fd0494 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,74 @@ -# Nova Liveboard +# nova_liveboard -Real-time BEAM VM dashboard for [Nova](https://github.com/novaframework/nova) — the Erlang equivalent of Phoenix LiveDashboard. +Real-time BEAM VM dashboard for [Nova](https://novaframework.org) - a +self-hosted, dependency-light "mission control" for a running node. Built on +**Nova + [Datastar](https://data-star.dev)**: server-rendered HTML, live +updates over SSE, no JavaScript build step, no off-origin requests. -Built on [Arizona](https://github.com/Taure/arizona_core) for live differential rendering over WebSocket. +## What it shows -## Pages +| Page | Live view | +|------|-----------| +| Overview | OTP/ERTS, schedulers, capacity gauges, memory breakdown | +| Metrics | sparklines (memory, run queue, IO) + scheduler utilisation | +| Processes | top processes by memory / reductions / message queue | +| Requests | every HTTP request the node handles, streaming in live, with an opt-in deep trace of the **processes each request spawns** | +| Supervisors | the live supervision tree per application | +| Applications / ETS / Ports | running apps, ETS tables, open ports | +| Database / Schemas | Kura repos, pools and schemas (when Kura is present) | -| Page | Description | Refresh | -|------|-------------|---------| -| **System** | OTP release, uptime, schedulers, memory breakdown with usage bars | 2s | -| **Processes** | Top 50 processes by memory/reductions/message queue, sortable | 2s | -| **ETS** | All ETS tables with type, protection, size, memory, owner | 3s | -| **Applications** | Running applications with versions | 5s | -| **Ports** | Open ports with I/O stats | 3s | -| **Supervisors** | App selector with supervision tree visualization | 5s | -| **Metrics** | Live sparkline charts (memory, processes, IO, run queue) + scheduler utilization bars | 2s | +The vitals deck across the top stays live on every page. -## Setup +## Install -Add to your Nova application's `rebar.config`: +Add it as a Nova app in your release and mount it via `nova_apps`: ```erlang {deps, [ - {nova_liveboard, {git, "https://github.com/novaframework/nova_liveboard.git", {branch, "master"}}} + {nova_liveboard, + {git, "https://github.com/novaframework/nova_liveboard.git", {branch, "master"}}} ]}. ``` -Add `nova_liveboard` to your application's dependencies in your `.app.src`: - ```erlang -{applications, [kernel, stdlib, nova, nova_liveboard]} +%% your sys.config +{your_app, [{nova_apps, [nova_liveboard]}]}. +{nova_liveboard, [{prefix, "/liveboard"}]}. ``` -Configure the liveboard in your `sys.config`: +Open `/liveboard`. + +## Request tracing + +The Requests page needs the tracing plugin, registered globally so it sees +every route on the node: ```erlang -{nova, [ - {nova_apps, [ - #{name => nova_liveboard, prefix => "/liveboard"} - ]} -]} +{nova, [{plugins, [ + {pre_request, nova_liveboard_trace_plugin, #{}}, + {post_request, nova_liveboard_trace_plugin, #{}} +]}]}. ``` -Visit `http://localhost:8080/liveboard` in your browser. +The request feed (method, path, status, duration, reductions, handler) is +always on and cheap. "Arm next 10" turns on scoped process tracing for the next +few requests, so you can open a request and see the exact tree of processes it +spawned - overhead is only paid while armed. -## Dependencies +## Configuration -- [Nova](https://github.com/novaframework/nova) — Erlang web framework -- [Arizona Core](https://github.com/Taure/arizona_core) — Live view engine with compile-time template optimization -- [Arizona Nova](https://github.com/Taure/arizona_nova) — Bridge between Arizona and Nova (WebSocket controller, PubSub) +| Key | Default | Meaning | +|-----|---------|---------| +| `prefix` | `"/liveboard"` | mount path | +| `refresh_ms` | `2000` | repaint interval for polled streams | +| `request_buffer` | `200` | recent requests retained | -## Architecture +## Privacy -- **Data layer** (`nova_liveboard_data`) — Pure functions collecting VM metrics, supervision trees, scheduler wall time deltas, sparkline point generation -- **Views** — Arizona views with `arizona_parse_transform` for compile-time template optimization -- **WebSocket** — Thin wrapper around `arizona_nova_websocket` with `flatten_reply` to bridge Arizona's list-based frame replies to Nova's single-frame `handle_ws` callback -- **Routing** — Nova router with WebSocket route defined after `/:page` to ensure `routing_tree` matches the exact `/live` path before the binding +Fonts, CSS and datastar.js are all self-hosted under `priv/static/assets`, and +the dashboard sends a strict `Content-Security-Policy` of `default-src 'self'`. +Nothing is fetched off-origin. -## License +## Licence -Apache-2.0 +Apache-2.0. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..515d029 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,38 @@ +[changelog] +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^refactor", group = "Refactor" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore", group = "Miscellaneous" }, + { message = "^ci", skip = true }, +] +protect_breaking_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "oldest" diff --git a/priv/static/assets/css/app.css b/priv/static/assets/css/app.css new file mode 100644 index 0000000..e5a26fb --- /dev/null +++ b/priv/static/assets/css/app.css @@ -0,0 +1,263 @@ +/* nova_liveboard - "BEAM mission control" + * + * A telemetry-grade instrument panel for a running BEAM node. Deep-space ink, + * a live vitals deck that breathes, nova-gold + signal-cyan accents, IBM Plex + * Mono throughout. Everything self-hosted (fonts + datastar.js); the page CSP + * is strict 'self', so there are no off-origin requests. */ + +@font-face { + font-family: "Plex Mono"; + src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); + font-weight: 400; font-style: normal; font-display: swap; +} +@font-face { + font-family: "Plex Mono"; + src: url("../fonts/ibm-plex-mono-latin-400-italic.woff2") format("woff2"); + font-weight: 400; font-style: italic; font-display: swap; +} +@font-face { + font-family: "Plex Mono"; + src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); + font-weight: 500; font-style: normal; font-display: swap; +} +@font-face { + font-family: "Plex Mono"; + src: url("../fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2"); + font-weight: 600; font-style: normal; font-display: swap; +} + +:root { + --void: #06080d; + --ink: #0a0e16; + --ink-2: #0e1320; + --ink-3: #151c2c; + --ink-4: #1d2740; + --line: rgba(170, 190, 230, 0.08); + --line-2: rgba(170, 190, 230, 0.16); + --text: #e8ecf6; + --dim: #98a4c0; + --faint: #5d6884; + --gold: #f5c451; + --gold-2: #ffd97a; + --gold-soft: rgba(245, 196, 81, 0.12); + --cyan: #5fd4e0; + --cyan-soft: rgba(95, 212, 224, 0.13); + --ok: #6cdb9b; + --warm: #e8b54a; + --bad: #ff6f6f; + --bad-soft: rgba(255, 111, 111, 0.13); + --mono: "Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --r: 10px; + --r-sm: 6px; +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + font-family: var(--mono); + font-size: 13px; + line-height: 1.55; + color: var(--text); + background-color: var(--void); + background-image: + radial-gradient(1200px 680px at 88% -10%, rgba(245, 196, 81, 0.08), transparent 60%), + radial-gradient(1000px 760px at 2% 112%, rgba(95, 212, 224, 0.06), transparent 55%), + linear-gradient(var(--line) 1px, transparent 1px), + linear-gradient(90deg, var(--line) 1px, transparent 1px); + background-size: auto, auto, 34px 34px, 34px 34px; + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +body::before { + content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.28; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); +} +a { color: inherit; text-decoration: none; } +::selection { background: var(--gold-soft); } +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: var(--ink-4); border-radius: 6px; } + +#deck { position: relative; z-index: 1; display: flex; flex-direction: column; height: 100%; } + +/* ---- masthead + vitals deck ---- */ +.masthead { + display: flex; align-items: center; gap: 26px; + padding: 12px 22px; border-bottom: 1px solid var(--line-2); + background: linear-gradient(180deg, rgba(14, 19, 32, 0.85), rgba(10, 14, 22, 0.5)); + backdrop-filter: blur(6px); +} +.brand { display: flex; align-items: baseline; gap: 11px; flex: none; } +.brand .nova { color: var(--gold); font-size: 18px; text-shadow: 0 0 14px rgba(245, 196, 81, 0.6); animation: twinkle 4s ease-in-out infinite; } +.wordmark { font-size: 16px; font-weight: 600; letter-spacing: 1.5px; text-transform: uppercase; color: var(--dim); } +.wordmark b { color: var(--text); font-weight: 600; } +.live { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: 1.4px; text-transform: uppercase; color: var(--cyan); } +.live .pip { width: 7px; height: 7px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px var(--cyan); animation: pulse 1.8s infinite; } + +.vitals { display: flex; gap: 22px; overflow-x: auto; flex: 1; padding-bottom: 2px; } +.vital { flex: 1 0 auto; min-width: 108px; } +.v-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 5px; } +.v-label { font-size: 9px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--faint); } +.v-val { font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; } +.v-lim { color: var(--faint); } +.v-big { font-size: 18px; font-weight: 500; font-variant-numeric: tabular-nums; color: var(--gold-2); } + +/* ---- shell: nav rail + stage ---- */ +.body { display: flex; flex: 1; min-height: 0; } +.rail { + flex: none; width: 208px; padding: 14px 12px; overflow-y: auto; + border-right: 1px solid var(--line); background: rgba(8, 11, 18, 0.5); +} +.rail .item { + display: flex; align-items: center; gap: 11px; padding: 9px 12px; margin-bottom: 2px; + border-radius: var(--r-sm); border-left: 2px solid transparent; color: var(--dim); + transition: background .15s ease, color .15s ease, border-color .15s ease; +} +.rail .item:hover { background: var(--ink-2); color: var(--text); } +.rail .item.active { background: var(--gold-soft); color: var(--gold-2); border-left-color: var(--gold); } +.rail .item .glyph { width: 16px; text-align: center; font-size: 13px; opacity: 0.85; } +.rail .item .label { font-size: 12px; letter-spacing: 0.4px; } +.nav-sep { margin: 16px 12px 7px; font-size: 8.5px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--faint); } + +.stage { flex: 1; min-width: 0; overflow-y: auto; padding: 24px 30px 60px; } +.page { max-width: 1240px; } + +/* ---- panels + grids ---- */ +.grid { display: grid; gap: 18px; } +.grid.two { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); } +.grid.spark { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); margin-bottom: 18px; } +.panel { + background: linear-gradient(180deg, var(--ink), var(--ink-2)); + border: 1px solid var(--line); border-radius: var(--r); padding: 16px 18px; + animation: rise .5s both; +} +.panel + .panel { margin-top: 18px; } +.p-title { margin: 0 0 14px; font-size: 9.5px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--faint); font-weight: 600; } +.p-body { min-width: 0; } + +.kv { display: flex; justify-content: space-between; gap: 16px; padding: 6px 0; border-bottom: 1px dashed var(--line); } +.kv:last-child { border-bottom: none; } +.kv .k { color: var(--faint); } +.kv .v { color: var(--text); text-align: right; font-variant-numeric: tabular-nums; } + +/* ---- bars / gauges ---- */ +.bar { height: 6px; border-radius: 4px; background: var(--ink-4); overflow: hidden; } +.bar .fill { display: block; height: 100%; border-radius: 4px; transition: width .6s cubic-bezier(.4,0,.2,1); } +.fill.cool { background: linear-gradient(90deg, var(--cyan), #7fe3ec); } +.fill.warm { background: linear-gradient(90deg, var(--gold), var(--gold-2)); } +.fill.hot { background: linear-gradient(90deg, var(--warm), var(--bad)); } + +.mrow { margin-bottom: 13px; } +.mrow:last-child { margin-bottom: 0; } +.m-top { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 5px; } +.m-label { color: var(--dim); } +.m-val { color: var(--text); font-variant-numeric: tabular-nums; } + +/* ---- metrics: spark cards + scheduler ---- */ +.card { + background: var(--ink); border: 1px solid var(--line); border-radius: var(--r); + padding: 14px 16px; animation: rise .5s both; +} +.c-label { font-size: 9px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--faint); } +.c-val { font-size: 21px; font-weight: 500; font-variant-numeric: tabular-nums; margin: 3px 0 8px; color: var(--gold-2); } +.spark-svg { width: 100%; height: 44px; display: block; } +.spark-svg polyline { fill: none; stroke: var(--cyan); stroke-width: 1.6; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 4px var(--cyan-soft)); } + +.sched { display: grid; grid-template-columns: 34px 1fr 44px; align-items: center; gap: 10px; padding: 4px 0; } +.s-id { font-size: 10.5px; color: var(--faint); } +.s-pct { font-size: 10.5px; text-align: right; color: var(--dim); font-variant-numeric: tabular-nums; } + +/* ---- toolbar + chips ---- */ +.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +.t-label { font-size: 9px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--faint); margin-right: 4px; } +.chip { + display: inline-block; font-family: var(--mono); font-size: 11px; letter-spacing: 0.4px; + color: var(--dim); background: var(--ink-3); border: 1px solid var(--line-2); + border-radius: 20px; padding: 5px 13px; cursor: pointer; transition: all .15s ease; +} +.chip:hover { color: var(--text); border-color: var(--faint); background: var(--ink-4); } +.chip.active { color: #1a1407; background: var(--gold); border-color: var(--gold); font-weight: 600; } +.chip.danger:hover { color: var(--bad); border-color: var(--bad); background: var(--bad-soft); } + +/* ---- tables ---- */ +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r); } +.table { width: 100%; border-collapse: collapse; font-size: 12px; } +.table th { + text-align: left; padding: 10px 14px; font-size: 9px; letter-spacing: 1.2px; text-transform: uppercase; + color: var(--faint); font-weight: 600; background: var(--ink-2); border-bottom: 1px solid var(--line-2); position: sticky; top: 0; +} +.table td { padding: 8px 14px; border-bottom: 1px solid var(--line); color: var(--dim); white-space: nowrap; } +.table tbody tr:hover td { background: rgba(95, 212, 224, 0.04); color: var(--text); } +.table tbody tr:last-child td { border-bottom: none; } +.table .strong { color: var(--text); font-weight: 500; } +.table .mono { color: var(--faint); } +.table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--text); } +.empty-cell { text-align: center; color: var(--faint); font-style: italic; padding: 22px; } + +/* ---- supervision / spawn tree ---- */ +.tree { font-size: 12px; line-height: 1.4; } +.node { + display: flex; align-items: center; gap: 9px; padding: 4px 0; + padding-left: calc(var(--depth, 0) * 22px); +} +.node .n-kind { width: 15px; text-align: center; font-size: 11px; } +.n-kind.sup { color: var(--gold); } +.n-kind.worker { color: var(--cyan); } +.n-name { color: var(--text); } +.n-pid { color: var(--faint); font-size: 11px; } +.n-mem { margin-left: auto; color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums; } + +/* ---- request feed ---- */ +.feed { display: flex; flex-direction: column; gap: 4px; } +.req { + display: grid; grid-template-columns: 58px 1fr 52px 84px 96px 60px minmax(0, 1.4fr); align-items: center; gap: 12px; + padding: 9px 14px; border: 1px solid var(--line); border-radius: var(--r-sm); + background: var(--ink); transition: border-color .15s ease, background .15s ease; + animation: rowin .42s cubic-bezier(.4,0,.2,1) both; +} +.req:hover { border-color: var(--line-2); background: var(--ink-2); } +.method { font-size: 9.5px; font-weight: 600; letter-spacing: 0.6px; text-align: center; padding: 2px 0; border-radius: 4px; } +.method.get { color: var(--cyan); background: var(--cyan-soft); } +.method.post { color: var(--gold-2); background: var(--gold-soft); } +.method.put { color: var(--warm); background: rgba(232, 181, 74, 0.13); } +.method.delete { color: var(--bad); background: var(--bad-soft); } +.method.other { color: var(--faint); background: var(--ink-3); } +.r-path { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.status { font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; font-weight: 500; } +.status.s2 { color: var(--ok); } .status.s3 { color: var(--cyan); } +.status.s4 { color: var(--warm); } .status.s5 { color: var(--bad); } +.r-dur, .r-reds { font-size: 11px; color: var(--dim); text-align: right; font-variant-numeric: tabular-nums; } +.r-spawn { font-size: 11px; color: var(--gold); text-align: center; } +.r-handler { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.req-head { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.rh-path { font-size: 16px; color: var(--text); } + +.hint { max-width: 560px; margin: 40px auto; text-align: center; } +.hint h2 { font-size: 15px; font-weight: 600; letter-spacing: 0.5px; color: var(--gold-2); } +.hint p { color: var(--dim); } +.code { + text-align: left; font-family: var(--mono); font-size: 11.5px; color: var(--cyan); + background: var(--ink-2); border: 1px solid var(--line-2); border-radius: var(--r); + padding: 16px 18px; margin-top: 18px; overflow-x: auto; +} + +/* ---- status pips ---- */ +.pip { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.pip.ok { background: var(--ok); box-shadow: 0 0 6px rgba(108, 219, 155, 0.6); } +.pip.warm { background: var(--warm); } +.pip.bad { background: var(--bad); box-shadow: 0 0 6px rgba(255, 111, 111, 0.6); animation: blink 1.3s steps(1) infinite; } +.pip.idle { background: var(--faint); } + +.empty { color: var(--faint); font-style: italic; padding: 18px 4px; } + +/* ---- motion ---- */ +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(95, 212, 224, 0.5); } 70% { box-shadow: 0 0 0 7px rgba(95, 212, 224, 0); } 100% { box-shadow: 0 0 0 0 rgba(95, 212, 224, 0); } } +@keyframes blink { 50% { opacity: 0.3; } } +@keyframes twinkle { 0%, 100% { opacity: 1; } 45% { opacity: 0.55; } } +@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@keyframes rowin { from { opacity: 0; transform: translateY(-7px); } to { opacity: 1; transform: none; } } + +@media (max-width: 720px) { + .rail { width: 56px; } .rail .item .label, .nav-sep { display: none; } + .req { grid-template-columns: 52px 1fr 48px; } .req .r-dur, .req .r-reds, .req .r-spawn, .req .r-handler { display: none; } +} diff --git a/priv/static/assets/css/liveboard.css b/priv/static/assets/css/liveboard.css deleted file mode 100644 index 3b4f30e..0000000 --- a/priv/static/assets/css/liveboard.css +++ /dev/null @@ -1,63 +0,0 @@ -* { margin: 0; padding: 0; box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; } -.nav { background: #1e293b; border-bottom: 1px solid #334155; padding: 0 1.5rem; display: flex; align-items: center; gap: 2rem; height: 3.5rem; } -.nav-brand { font-size: 1.1rem; font-weight: 700; color: #38bdf8; letter-spacing: -0.02em; } -.nav-links { display: flex; gap: 0.25rem; } -.nav-links a { padding: 0.5rem 1rem; color: #94a3b8; text-decoration: none; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 500; transition: all 0.15s; } -.nav-links a:hover { color: #e2e8f0; background: #334155; } -.nav-links a.active { color: #38bdf8; background: #0f172a; } -.main { padding: 1.5rem; max-width: 90rem; margin: 0 auto; } -.card { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1.25rem; margin-bottom: 1rem; } -.card-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 0.75rem; } -.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } -.stat { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1rem; } -.stat-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } -.stat-value { font-size: 1.5rem; font-weight: 700; color: #f1f5f9; margin-top: 0.25rem; font-variant-numeric: tabular-nums; } -.stat-sub { font-size: 0.75rem; color: #475569; margin-top: 0.125rem; } -table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } -th { text-align: left; padding: 0.625rem 0.75rem; color: #64748b; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #334155; } -td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #1e293b; font-variant-numeric: tabular-nums; } -tr:hover td { background: #1e293b; } -.mono { font-family: "SF Mono", "Cascadia Code", "Fira Code", monospace; font-size: 0.8125rem; } -.text-right { text-align: right; } -.text-blue { color: #38bdf8; } -.text-green { color: #4ade80; } -.text-amber { color: #fbbf24; } -.text-dim { color: #475569; } -.bar { height: 0.375rem; background: #334155; border-radius: 9999px; overflow: hidden; } -.bar-fill { height: 100%; border-radius: 9999px; transition: width 0.3s; } -.bar-fill-blue { background: #38bdf8; } -.bar-fill-green { background: #4ade80; } -.bar-fill-amber { background: #fbbf24; } -.bar-fill-red { background: #f87171; } -.sort-btn { background: none; border: none; color: #64748b; cursor: pointer; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.625rem 0.75rem; width: 100%; text-align: left; } -.sort-btn:hover { color: #e2e8f0; } -.sort-btn.active { color: #38bdf8; } -.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; } -.badge-green { background: #166534; color: #4ade80; } -.badge-blue { background: #1e3a5f; color: #38bdf8; } -.badge-amber { background: #78350f; color: #fbbf24; } -.text-red { color: #f87171; } -.nav-sep { width: 1px; height: 1.25rem; background: #334155; margin: 0 0.25rem; } -.refresh-info { font-size: 0.75rem; color: #475569; text-align: right; margin-bottom: 0.5rem; } - -/* Supervision Tree */ -.app-selector { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 1rem; } -.app-btn { background: #1e293b; border: 1px solid #334155; color: #94a3b8; padding: 0.375rem 0.75rem; border-radius: 0.375rem; font-size: 0.8125rem; cursor: pointer; transition: all 0.15s; } -.app-btn:hover { color: #e2e8f0; border-color: #475569; } -.app-btn.active { color: #38bdf8; border-color: #38bdf8; background: #0f172a; } -.tree-flat { display: flex; flex-direction: column; } -.tree-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem; border-radius: 0.375rem; border-bottom: 1px solid #1e293b; } -.tree-row:hover { background: #334155; } -.tree-meta { display: flex; gap: 0.75rem; margin-left: auto; font-size: 0.8125rem; align-items: center; } - -/* Metrics */ -.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } -.metric-card { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1rem; } -.metric-current { font-size: 1.5rem; font-weight: 700; color: #f1f5f9; margin: 0.25rem 0 0.5rem; font-variant-numeric: tabular-nums; } -.sparkline { width: 100%; height: 40px; display: block; } -.sparkline polyline { fill: none; stroke: #38bdf8; stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; } -.scheduler-bars { display: flex; flex-direction: column; gap: 0.375rem; padding: 0.5rem; } -.sched-row { display: flex; align-items: center; gap: 0.75rem; } -.sched-label { width: 2rem; text-align: right; font-size: 0.8125rem; color: #64748b; } -.sched-pct { width: 3rem; text-align: right; font-size: 0.8125rem; color: #94a3b8; } diff --git a/priv/static/assets/fonts/NOTICE b/priv/static/assets/fonts/NOTICE new file mode 100644 index 0000000..3c354ec --- /dev/null +++ b/priv/static/assets/fonts/NOTICE @@ -0,0 +1,13 @@ +Self-hosted web fonts for gakudan_liveboard. + +These WOFF2 files are bundled so the dashboard makes no off-origin requests +(GDPR by design). They are fetched by scripts/fetch-fonts.sh. Both families are +licensed under the SIL Open Font License 1.1 (OFL), which permits bundling and +redistribution: + +- IBM Plex Mono - (c) IBM Corp. - https://github.com/IBM/plex +- Instrument Serif- (c) Rosetta Type / Instrument - https://github.com/Instrument/instrument-serif + +SIL OFL 1.1: https://openfontlicense.org/ + +Latin subset only; regenerate with `make fonts` (scripts/fetch-fonts.sh). diff --git a/priv/static/assets/fonts/ibm-plex-mono-latin-400-italic.woff2 b/priv/static/assets/fonts/ibm-plex-mono-latin-400-italic.woff2 new file mode 100644 index 0000000..855c41c Binary files /dev/null and b/priv/static/assets/fonts/ibm-plex-mono-latin-400-italic.woff2 differ diff --git a/priv/static/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 b/priv/static/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/priv/static/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/priv/static/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 b/priv/static/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/priv/static/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/priv/static/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 b/priv/static/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 new file mode 100644 index 0000000..67aeeb0 Binary files /dev/null and b/priv/static/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 differ diff --git a/priv/static/assets/js/arizona.min.js b/priv/static/assets/js/arizona.min.js deleted file mode 100644 index 54f676d..0000000 --- a/priv/static/assets/js/arizona.min.js +++ /dev/null @@ -1,534 +0,0 @@ -const Z = '(function(){"use strict";class n{constructor(){this.structure=new Map}initialize(t){this.structure=new Map(Object.entries(JSON.parse(JSON.stringify(t))))}mergeStructures(t){for(const[e,s]of Object.entries(t))this.structure.set(e,s)}applyDiff(t,e){if(e?.type==="stateful"){this.structure.set(e.id,e);return}if(!this.structure.has(t)){const r=String(t).replace(/\\r|\\n/g,"");console.warn(`[Arizona] StatefulId \'${r}\' not found in structure`)}const s=this.structure.get(t);for(const[r,i]of e)this.applyDiffValue(s.dynamic,r-1,i)}applyDiffValue(t,e,s){const r=t[e];if(r?.type&&Array.isArray(s))switch(r.type){case"stateful":{const i=this.structure.get(r.id);if(!i){const a=String(r.id).replace(/\\r|\\n/g,"");console.warn(`[Arizona] Component \'${a}\' not found in structure`);return}s.forEach(([a,h])=>{this.applyDiffValue(i.dynamic,a-1,h)});return}case"stateless":s.forEach(([i,a])=>{this.applyDiffValue(r.dynamic,i-1,a)});return;case"list":r.dynamic=s;return}t[e]=s}generateStatefulHTML(t){const e=this.structure.get(t);if(!e){const r=String(t).replace(/\\r|\\n/g,"");throw console.warn(`[Arizona] StatefulId \'${r}\' not found in structure`),new Error(`Component ${r} not found`)}return this.zipStaticDynamic(e.static,e.dynamic)}generateStatelessHTML(t){return this.zipStaticDynamic(t.static,t.dynamic)}generateListHTML(t){const{static:e,dynamic:s}=t;return s.reduce((r,i)=>r+this.zipStaticDynamic(e,i),"")}zipStaticDynamic(t,e){const s=[],r=Math.max(t.length,e.length);for(let i=0;ithis.flattenIoData(e)).join(""):t&&typeof t=="object"?t.type==="stateful"?this.generateStatefulHTML(t.id):t.type==="stateless"?this.generateStatelessHTML(t):t.type==="list"?this.generateListHTML(t):String(t):String(t||"")}getStructure(){return JSON.parse(JSON.stringify(Object.fromEntries(this.structure)))}isInitialized(){return this.structure.size>0}getComponentIds(){return Array.from(this.structure.keys())}clear(){this.structure=new Map}createPatch(t){const e=this.generateStatefulHTML(t);return{type:"html_patch",statefulId:t,html:e}}}class c{constructor(){this.socket=null,this.connected=!1,this.messageQueue=[],this.hierarchical=new n,self.onmessage=t=>{if(!t.data||typeof t.data!="object"||!t.data.type)return;const{type:e,data:s}=t.data;switch(e){case"connect":this.connect(s.url,s.expectedHost);break;case"send":this.sendMessage(s);break;case"disconnect":this.disconnect();break}}}connect(t,e){if(!this.connected){if(!this.isValidWebSocketUrl(t,e)){this.postMessage({type:"error",data:{error:"Invalid WebSocket URL"}});return}this.socket=new WebSocket(t),this.socket.onopen=()=>{this.connected=!0,this.postMessage({type:"status",data:{status:"connected"}}),this.flushMessageQueue()},this.socket.onmessage=s=>{const r=JSON.parse(s.data);this.handleWebSocketMessage(r)},this.socket.onclose=()=>{this.connected=!1,this.postMessage({type:"status",data:{status:"disconnected"}})},this.socket.onerror=s=>{this.postMessage({type:"error",data:{error:s.toString()}})}}}isValidWebSocketUrl(t,e){try{const s=new URL(t);return!(s.protocol!=="ws:"&&s.protocol!=="wss:"||e&&s.host!==e||s.pathname.includes("../")||s.pathname.includes("..\\\\"))}catch{return!1}}sendMessage(t){const e=JSON.stringify(t);this.connected&&this.socket.readyState===WebSocket.OPEN?this.socket.send(e):this.messageQueue.push(e)}flushMessageQueue(){for(;this.messageQueue.length>0;){const t=this.messageQueue.shift();if(this.socket.readyState===WebSocket.OPEN)this.socket.send(t);else{this.messageQueue.unshift(t);break}}}disconnect(){this.socket&&(this.socket.close(),this.socket=null),this.connected=!1,this.hierarchical.clear()}handleWebSocketMessage(t){try{switch(t.type){case"initial_render":this.handleInitialRender(t);break;case"diff":this.handleDiff(t);break;case"reload":this.handleReload(t);break;case"dispatch":this.handleDispatch(t);break;case"reply":this.handleReply(t);break;case"redirect":this.handleRedirect(t);break;default:this.handleUnknownMessage(t)}}catch(e){this.postMessage({type:"error",data:{error:`Message handling failed: ${e.message}`}})}}handleInitialRender(t){this.hierarchical.initialize(t.structure)}handleDiff(t){if(!this.hierarchical.isInitialized())throw new Error("Hierarchical structure not initialized");t.structure&&Object.keys(t.structure).length>0&&this.hierarchical.mergeStructures(t.structure),this.hierarchical.applyDiff(t.stateful_id,t.changes);const e=this.hierarchical.createPatch(t.stateful_id);this.postMessage({type:"html_patch",data:{patch:e}})}handleReload(t){this.postMessage({type:"reload",data:t})}handleDispatch(t){this.postMessage({type:"dispatch",data:t})}handleReply(t){this.postMessage({type:"reply",data:t})}handleRedirect(t){this.postMessage({type:"redirect",data:{url:t.url,target:t.target}})}handleUnknownMessage(t){this.postMessage(t)}postMessage(t){self.postMessage(t)}}new c})();\n//# sourceMappingURL=arizona-worker.min.js.map\n', G = typeof self < "u" && self.Blob && new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);", Z], { type: "text/javascript;charset=utf-8" }); -function le(n) { - let e; - try { - if (e = G && (self.URL || self.webkitURL).createObjectURL(G), !e) throw ""; - const t = new Worker(e, { - name: n?.name - }); - return t.addEventListener("error", () => { - (self.URL || self.webkitURL).revokeObjectURL(e); - }), t; - } catch { - return new Worker( - "data:text/javascript;charset=utf-8," + encodeURIComponent(Z), - { - name: n?.name - } - ); - } -} -var J = 11; -function oe(n, e) { - var t = e.attributes, r, s, l, f, p; - if (!(e.nodeType === J || n.nodeType === J)) { - for (var d = t.length - 1; d >= 0; d--) - r = t[d], s = r.name, l = r.namespaceURI, f = r.value, l ? (s = r.localName || s, p = n.getAttributeNS(l, s), p !== f && (r.prefix === "xmlns" && (s = r.name), n.setAttributeNS(l, s, f))) : (p = n.getAttribute(s), p !== f && n.setAttribute(s, f)); - for (var b = n.attributes, T = b.length - 1; T >= 0; T--) - r = b[T], s = r.name, l = r.namespaceURI, l ? (s = r.localName || s, e.hasAttributeNS(l, s) || n.removeAttributeNS(l, s)) : e.hasAttribute(s) || n.removeAttribute(s); - } -} -var E, he = "http://www.w3.org/1999/xhtml", g = typeof document > "u" ? void 0 : document, de = !!g && "content" in g.createElement("template"), ue = !!g && g.createRange && "createContextualFragment" in g.createRange(); -function fe(n) { - var e = g.createElement("template"); - return e.innerHTML = n, e.content.childNodes[0]; -} -function ge(n) { - E || (E = g.createRange(), E.selectNode(g.body)); - var e = E.createContextualFragment(n); - return e.childNodes[0]; -} -function pe(n) { - var e = g.createElement("body"); - return e.innerHTML = n, e.childNodes[0]; -} -function ve(n) { - return n = n.trim(), de ? fe(n) : ue ? ge(n) : pe(n); -} -function _(n, e) { - var t = n.nodeName, r = e.nodeName, s, l; - return t === r ? !0 : (s = t.charCodeAt(0), l = r.charCodeAt(0), s <= 90 && l >= 97 ? t === r.toUpperCase() : l <= 90 && s >= 97 ? r === t.toUpperCase() : !1); -} -function ye(n, e) { - return !e || e === he ? g.createElement(n) : g.createElementNS(e, n); -} -function me(n, e) { - for (var t = n.firstChild; t; ) { - var r = t.nextSibling; - e.appendChild(t), t = r; - } - return e; -} -function j(n, e, t) { - n[t] !== e[t] && (n[t] = e[t], n[t] ? n.setAttribute(t, "") : n.removeAttribute(t)); -} -var q = { - OPTION: function(n, e) { - var t = n.parentNode; - if (t) { - var r = t.nodeName.toUpperCase(); - r === "OPTGROUP" && (t = t.parentNode, r = t && t.nodeName.toUpperCase()), r === "SELECT" && !t.hasAttribute("multiple") && (n.hasAttribute("selected") && !e.selected && (n.setAttribute("selected", "selected"), n.removeAttribute("selected")), t.selectedIndex = -1); - } - j(n, e, "selected"); - }, - /** - * The "value" attribute is special for the element since it sets - * the initial value. Changing the "value" attribute without changing the - * "value" property will have no effect since it is only used to the set the - * initial value. Similar for the "checked" attribute, and "disabled". - */ - INPUT: function(n, e) { - j(n, e, "checked"), j(n, e, "disabled"), n.value !== e.value && (n.value = e.value), e.hasAttribute("value") || n.removeAttribute("value"); - }, - TEXTAREA: function(n, e) { - var t = e.value; - n.value !== t && (n.value = t); - var r = n.firstChild; - if (r) { - var s = r.nodeValue; - if (s == t || !t && s == n.placeholder) - return; - r.nodeValue = t; - } - }, - SELECT: function(n, e) { - if (!e.hasAttribute("multiple")) { - for (var t = -1, r = 0, s = n.firstChild, l, f; s; ) - if (f = s.nodeName && s.nodeName.toUpperCase(), f === "OPTGROUP") - l = s, s = l.firstChild, s || (s = l.nextSibling, l = null); - else { - if (f === "OPTION") { - if (s.hasAttribute("selected")) { - t = r; - break; - } - r++; - } - s = s.nextSibling, !s && l && (s = l.nextSibling, l = null); - } - n.selectedIndex = t; - } - } -}, A = 1, K = 11, X = 3, Y = 8; -function w() { -} -function we(n) { - if (n) - return n.getAttribute && n.getAttribute("id") || n.id; -} -function be(n) { - return function(t, r, s) { - if (s || (s = {}), typeof r == "string") - if (t.nodeName === "#document" || t.nodeName === "HTML") { - var l = r; - r = g.createElement("html"), r.innerHTML = l; - } else if (t.nodeName === "BODY") { - var f = r; - r = g.createElement("html"), r.innerHTML = f; - var p = r.querySelector("body"); - p && (r = p); - } else - r = ve(r); - else r.nodeType === K && (r = r.firstElementChild); - var d = s.getNodeKey || we, b = s.onBeforeNodeAdded || w, T = s.onNodeAdded || w, ee = s.onBeforeElUpdated || w, te = s.onElUpdated || w, re = s.onBeforeNodeDiscarded || w, L = s.onNodeDiscarded || w, se = s.onBeforeElChildrenUpdated || w, ne = s.skipFromChildren || w, V = s.addChild || function(i, a) { - return i.appendChild(a); - }, x = s.childrenOnly === !0, k = /* @__PURE__ */ Object.create(null), R = []; - function U(i) { - R.push(i); - } - function B(i, a) { - if (i.nodeType === A) - for (var h = i.firstChild; h; ) { - var c = void 0; - a && (c = d(h)) ? U(c) : (L(h), h.firstChild && B(h, a)), h = h.nextSibling; - } - } - function D(i, a, h) { - re(i) !== !1 && (a && a.removeChild(i), L(i), B(i, h)); - } - function I(i) { - if (i.nodeType === A || i.nodeType === K) - for (var a = i.firstChild; a; ) { - var h = d(a); - h && (k[h] = a), I(a), a = a.nextSibling; - } - } - I(t); - function z(i) { - T(i); - for (var a = i.firstChild; a; ) { - var h = a.nextSibling, c = d(a); - if (c) { - var o = k[c]; - o && _(a, o) ? (a.parentNode.replaceChild(o, a), O(o, a)) : z(a); - } else - z(a); - a = h; - } - } - function ie(i, a, h) { - for (; a; ) { - var c = a.nextSibling; - (h = d(a)) ? U(h) : D( - a, - i, - !0 - /* skip keyed nodes */ - ), a = c; - } - } - function O(i, a, h) { - var c = d(a); - if (c && delete k[c], !h) { - var o = ee(i, a); - if (o === !1 || (o instanceof HTMLElement && (i = o, I(i)), n(i, a), te(i), se(i, a) === !1)) - return; - } - i.nodeName !== "TEXTAREA" ? ae(i, a) : q.TEXTAREA(i, a); - } - function ae(i, a) { - var h = ne(i, a), c = a.firstChild, o = i.firstChild, S, v, M, N, y; - e: for (; c; ) { - for (N = c.nextSibling, S = d(c); !h && o; ) { - if (M = o.nextSibling, c.isSameNode && c.isSameNode(o)) { - c = N, o = M; - continue e; - } - v = d(o); - var H = o.nodeType, m = void 0; - if (H === c.nodeType && (H === A ? (S ? S !== v && ((y = k[S]) ? M === y ? m = !1 : (i.insertBefore(y, o), v ? U(v) : D( - o, - i, - !0 - /* skip keyed nodes */ - ), o = y, v = d(o)) : m = !1) : v && (m = !1), m = m !== !1 && _(o, c), m && O(o, c)) : (H === X || H == Y) && (m = !0, o.nodeValue !== c.nodeValue && (o.nodeValue = c.nodeValue))), m) { - c = N, o = M; - continue e; - } - v ? U(v) : D( - o, - i, - !0 - /* skip keyed nodes */ - ), o = M; - } - if (S && (y = k[S]) && _(y, c)) - h || V(i, y), O(y, c); - else { - var W = b(c); - W !== !1 && (W && (c = W), c.actualize && (c = c.actualize(i.ownerDocument || g)), V(i, c), z(c)); - } - c = N, o = M; - } - ie(i, o, v); - var Q = q[i.nodeName]; - Q && Q(i, a); - } - var u = t, C = u.nodeType, F = r.nodeType; - if (!x) { - if (C === A) - F === A ? _(t, r) || (L(t), u = me(t, ye(r.nodeName, r.namespaceURI))) : u = r; - else if (C === X || C === Y) { - if (F === C) - return u.nodeValue !== r.nodeValue && (u.nodeValue = r.nodeValue), u; - u = r; - } - } - if (u === r) - L(t); - else { - if (r.isSameNode && r.isSameNode(u)) - return; - if (O(u, r, x), R) - for (var P = 0, ce = R.length; P < ce; P++) { - var $ = k[R[P]]; - $ && D($, $.parentNode, !1); - } - } - return !x && u !== t && t.parentNode && (u.actualize && (u = u.actualize(t.ownerDocument || g)), t.parentNode.replaceChild(u, t)), u; - }; -} -var ke = be(oe); -class Se { - /** - * Creates a new Arizona client instance - * @param {ArizonaOptions} [opts={}] - Client configuration options - */ - constructor(e = {}) { - this.worker = null, this.connected = !1, this.eventListeners = /* @__PURE__ */ new Map(), this.logger = e.logger || null, this.nextRefId = 0, this.pendingCalls = /* @__PURE__ */ new Map(); - } - /** - * Initialize worker if not already created - * @private - * @returns {void} - */ - initializeWorker() { - this.worker || (this.worker = new le(), this.worker.onmessage = (e) => { - this.handleWorkerMessage(e.data); - }); - } - /** - * Connect to the Arizona WebSocket server - * @param {string} websocketEndpoint - WebSocket endpoint path - * @returns {void} - */ - connect(e) { - if (this.connected) return; - this.initializeWorker(); - const t = window.location.protocol === "https:" ? "wss:" : "ws:", r = window.location.host, s = window.location.pathname, l = window.location.search, f = encodeURIComponent(s), p = l ? encodeURIComponent(l.substring(1)) : "", d = `${t}//${r}${e}?path=${f}&qs=${p}`; - this.worker.postMessage({ - type: "connect", - data: { url: d, expectedHost: r } - }); - } - /** - * Push an event to the Arizona server - * @param {string} event - Event name - * @param {EventParams} [params={}] - Event parameters - * @returns {void} - */ - pushEvent(e, t = {}) { - this.connected && this.worker.postMessage({ - type: "send", - data: { - type: "event", - event: e, - params: t - } - }); - } - /** - * Push an event to a specific stateful component - * @param {string} statefulId - Target stateful component ID - * @param {string} event - Event name - * @param {EventParams} [params={}] - Event parameters - * @returns {void} - */ - pushEventTo(e, t, r = {}) { - this.connected && this.worker.postMessage({ - type: "send", - data: { - type: "event", - stateful_id: e, - event: t, - params: r - } - }); - } - /** - * Call an event on the Arizona server and wait for reply - * @param {string} event - Event name - * @param {EventParams} [params={}] - Event parameters - * @param {Object} [options={}] - Call options - * @param {number} [options.timeout=10000] - Timeout in milliseconds - * @returns {Promise<*>} Promise that resolves with reply data - */ - callEvent(e, t = {}, r = {}) { - return this._callEvent(void 0, e, t, r); - } - /** - * Call an event on a specific stateful component and wait for reply - * @param {string} statefulId - Target stateful component ID - * @param {string} event - Event name - * @param {EventParams} [params={}] - Event parameters - * @param {Object} [options={}] - Call options - * @param {number} [options.timeout=10000] - Timeout in milliseconds - * @returns {Promise<*>} Promise that resolves with reply data - */ - callEventFrom(e, t, r = {}, s = {}) { - return this._callEvent(e, t, r, s); - } - /** - * Internal helper to call an event and wait for reply - * @private - * @param {string|undefined} statefulId - Target stateful component ID (undefined for view) - * @param {string} event - Event name - * @param {EventParams} params - Event parameters - * @param {Object} options - Call options - * @returns {Promise<*>} Promise that resolves with reply data - */ - _callEvent(e, t, r, s) { - if (!this.connected) return Promise.reject(new Error("Not connected")); - const l = `${++this.nextRefId}`; - return new Promise((f, p) => { - const d = setTimeout(() => { - this.pendingCalls.delete(l), p(new Error(`Call timeout: ${t}`)); - }, s.timeout || 1e4); - this.pendingCalls.set(l, { resolve: f, reject: p, timeout: d }); - const b = { - type: "event", - ref_id: l, - event: t, - params: r - }; - e !== void 0 && (b.stateful_id = e), this.worker.postMessage({ - type: "send", - data: b - }); - }); - } - /** - * Disconnect from the Arizona WebSocket server - * @returns {void} - */ - disconnect() { - this.worker && (this.worker.terminate(), this.worker = null), this.connected = !1, this.pendingCalls.forEach((e) => { - clearTimeout(e.timeout), e.reject(new Error("Disconnected")); - }), this.pendingCalls.clear(); - } - /** - * Handle messages from the worker thread - * @private - * @param {Object} message - Worker message - * @returns {void} - */ - handleWorkerMessage(e) { - const { type: t, data: r } = e; - try { - switch (t) { - case "status": - this.handleStatus(r); - break; - case "initial_render": - break; - case "html_patch": - this.handleHtmlPatch(r); - break; - case "error": - this.handleWorkerError(r); - break; - case "reload": - this.handleReload(r); - break; - case "dispatch": - this.handleDispatch(r); - break; - case "reply": - this.handleReply(r); - break; - case "redirect": - this.handleRedirect(r); - break; - default: - this.handleUnknownMessage(e); - } - } catch (s) { - this.logger?.error("Error handling worker message:", s); - } - } - handleStatus(e) { - e.status === "connected" ? (this.connected = !0, this.logger?.info("Connected to WebSocket"), this.emit("connected", e)) : e.status === "disconnected" && (this.connected = !1, this.logger?.info("Disconnected from WebSocket"), this.emit("disconnected", e)); - } - handleHtmlPatch(e) { - this.logger?.debug("Applying HTML patch"), this.applyHtmlPatch(e.patch); - } - applyHtmlPatch(e) { - const t = document.getElementById(e.statefulId); - if (!t) { - console.warn("[Arizona] Target element not found:", e.statefulId), this.logger?.warning(`Target element not found: ${e.statefulId}`); - return; - } - try { - ke(t, e.html, { - onBeforeElUpdated(r, s) { - return s.dataset?.arizonaUpdate === "false" ? !1 : !r.isEqualNode(s); - } - }), this.logger?.debug("Patch applied successfully"); - } catch (r) { - this.logger?.error("Error applying HTML patch:", r); - } - } - handleWorkerError(e) { - this.logger?.error("Worker Error:", e.error), this.emit("error", e); - } - handleReload(e) { - e.file_type === "css" ? (this.logger?.info("CSS file changed. Refreshing stylesheets without page reload..."), document.querySelectorAll('link[rel="stylesheet"]').forEach((t) => { - const r = t.href.split("?")[0]; - t.href = `${r}?t=${Date.now()}`; - })) : (this.logger?.info(`${e.file_type || "File"} changed. Reloading page...`), window.location.reload()); - } - handleDispatch(e) { - this.logger?.debug("Dispatching event:", e.event), this.emit(e.event, e.data); - } - handleReply(e) { - const { ref_id: t, data: r } = e, s = this.pendingCalls.get(t); - s ? (clearTimeout(s.timeout), s.resolve(r), this.pendingCalls.delete(t), this.logger?.debug(`Reply received for ref: ${t}`)) : this.logger?.warning(`Received reply for unknown ref: ${t}`); - } - handleRedirect(e) { - this.logger?.info("Redirecting to:", e.url), window.open(e.url, e.options?.target, e.options?.window_features); - } - handleUnknownMessage(e) { - this.logger?.warning("Unknown worker message:", e); - } - /** - * Check if client is connected to server - * @returns {boolean} True if connected - */ - isConnected() { - return this.connected; - } - /** - * Subscribe to an Arizona event - * @param {string} event - Event name (e.g., 'connected', 'disconnected') - * @param {Function} callback - Callback function to invoke when event occurs - * @returns {Function} Unsubscribe function - */ - on(e, t) { - return typeof t != "function" ? (this.logger?.error(`on: callback must be a function, got ${typeof t}`), () => { - }) : (this.eventListeners.has(e) || this.eventListeners.set(e, /* @__PURE__ */ new Set()), this.eventListeners.get(e).add(t), this.logger?.debug(`Subscribed to event: ${e}`), () => this.off(e, t)); - } - /** - * Subscribe to an Arizona event that will only fire once - * @param {string} event - Event name - * @param {Function} callback - Callback function to invoke when event occurs - * @returns {Function} Unsubscribe function - */ - once(e, t) { - if (typeof t != "function") - return this.logger?.error(`once: callback must be a function, got ${typeof t}`), () => { - }; - const r = (s) => { - t(s), this.off(e, r); - }; - return this.on(e, r); - } - /** - * Unsubscribe from an Arizona event - * @param {string} event - Event name - * @param {Function} callback - Callback function to remove - * @returns {void} - */ - off(e, t) { - const r = this.eventListeners.get(e); - r && (r.delete(t), this.logger?.debug(`Unsubscribed from event: ${e}`), r.size === 0 && this.eventListeners.delete(e)); - } - /** - * Remove all listeners for a specific event, or all events if no event specified - * @param {string} [event] - Optional event name. If not provided, removes all listeners for all events - * @returns {void} - */ - removeAllListeners(e) { - e ? (this.eventListeners.delete(e), this.logger?.debug(`Removed all listeners for event: ${e}`)) : (this.eventListeners.clear(), this.logger?.debug("Removed all event listeners")); - } - /** - * Emit an Arizona event to all subscribed listeners - * @private - * @param {string} event - Event name - * @param {*} data - Event data to pass to listeners - * @returns {void} - */ - emit(e, t) { - const r = this.eventListeners.get(e); - r && r.forEach((s) => { - try { - s(t); - } catch (l) { - this.logger?.error(`Error in event listener for '${e}':`, l); - } - }); - } -} -export { - Se as default -}; -//# sourceMappingURL=arizona.min.js.map diff --git a/priv/static/assets/js/datastar.js b/priv/static/assets/js/datastar.js new file mode 100644 index 0000000..3cceb55 --- /dev/null +++ b/priv/static/assets/js/datastar.js @@ -0,0 +1,9 @@ +// Datastar v1.0.0 +var yt=/🖕JS_DS🚀/.source,Ue=yt.slice(0,5),Je=yt.slice(4),B="datastar-fetch",te="datastar-prop-change",vt="datastar-ready",Ke="datastar-scope-children",ne="datastar-signal-patch";var x=Object.hasOwn??Object.prototype.hasOwnProperty.call;var K=e=>e!==null&&typeof e=="object"&&(Object.getPrototypeOf(e)===Object.prototype||Object.getPrototypeOf(e)===null),bt=e=>{for(let t in e)if(x(e,t))return!1;return!0},re=(e,t)=>{for(let n in e){let r=e[n];K(r)||Array.isArray(r)?re(r,t):e[n]=t(r)}},Le=e=>{let t={};for(let[n,r]of e){let s=n.split("."),i=s.pop(),o=s.reduce((a,c)=>a[c]??={},t);o[i]=r}return t};var xe=[],ze=[],He=0,Ne=0,Ze=0,Qe,j,Pe=0,N=()=>{He++},P=()=>{--He||(Tt(),z())},_=e=>{Qe=j,j=e},k=()=>{j=Qe,Qe=void 0},me=e=>cn.bind(0,{previousValue:e,t:e,e:1}),Ye=Symbol("computed"),_e=e=>{let t=ln.bind(0,{e:17,getter:e});return t[Ye]=1,t},R=e=>{let t={d:e,e:2};j&&et(t,j),_(t),N();try{t.d()}finally{P(),k()}return Mt.bind(0,t)},Tt=()=>{for(;Ne"getter"in e?At(e):Rt(e,e.t),At=e=>{_(e),Lt(e);try{let t=e.t;return t!==(e.t=e.getter(t))}finally{k(),xt(e)}},Rt=(e,t)=>(e.e=1,e.previousValue!==(e.previousValue=t)),Xe=e=>{let t=e.e;if(!(t&64)){e.e=t|64;let n=e.r;n?Xe(n.o):ze[Ze++]=e}},wt=(e,t)=>{if(t&16||t&32&&Nt(e.s,e)){_(e),Lt(e),N();try{e.d()}finally{P(),k(),xt(e)}return}t&32&&(e.e=t&-33);let n=e.s;for(;n;){let r=n.c,s=r.e;s&64&&wt(r,r.e=s&-65),n=n.i}},cn=(e,...t)=>{if(t.length){if(e.t!==(e.t=t[0])){e.e=17;let r=e.r;return r&&(un(r),He||Tt()),!0}return!1}let n=e.t;if(e.e&16&&Rt(e,n)){let r=e.r;r&&Fe(r)}return j&&et(e,j),n},ln=e=>{let t=e.e;if(t&16||t&32&&Nt(e.s,e)){if(At(e)){let n=e.r;n&&Fe(n)}}else t&32&&(e.e=t&-33);return j&&et(e,j),e.t},Mt=e=>{let t=e.s;for(;t;)t=Ce(t,e);let n=e.r;n&&Ce(n),e.e=0},et=(e,t)=>{let n=t.a;if(n&&n.c===e)return;let r=n?n.i:t.s;if(r&&r.c===e){r.p=Pe,t.a=r;return}let s=e.m;if(s&&s.p===Pe&&s.o===t)return;let i=t.a=e.m={p:Pe,c:e,o:t,l:n,i:r,u:s};r&&(r.l=i),n?n.i=i:t.s=i,s?s.n=i:e.r=i},Ce=(e,t=e.o)=>{let n=e.c,r=e.l,s=e.i,i=e.n,o=e.u;if(s?s.l=r:t.a=r,r?r.i=s:t.s=s,i?i.u=o:n.m=o,o)o.n=i;else if(!(n.r=i))if("getter"in n){let a=n.s;if(a){n.e=17;do a=Ce(a,n);while(a)}}else"previousValue"in n||Mt(n);return s},un=e=>{let t=e.n,n;e:for(;;){let r=e.o,s=r.e;if(s&60?s&12?s&4?!(s&48)&&fn(e,r)?(r.e=s|40,s&=1):s=0:r.e=s&-9|32:s=0:r.e=s|32,s&2&&Xe(r),s&1){let i=r.r;if(i){let o=(e=i).n;o&&(n={t,f:n},t=o);continue}}if(e=t){t=e.n;continue}for(;n;)if(e=n.t,n=n.f,e){t=e.n;continue e}break}},Lt=e=>{Pe++,e.a=void 0,e.e=e.e&-57|4},xt=e=>{let t=e.a,n=t?t.i:e.s;for(;n;)n=Ce(n,e);e.e&=-5},Nt=(e,t)=>{let n,r=0,s=!1;e:for(;;){let i=e.c,o=i.e;if(t.e&16)s=!0;else if((o&17)===17){if(Et(i)){let a=i.r;a.n&&Fe(a),s=!0}}else if((o&33)===33){(e.n||e.u)&&(n={t:e,f:n}),e=i.s,t=i,++r;continue}if(!s){let a=e.i;if(a){e=a;continue}}for(;r--;){let a=t.r,c=a.n;if(c?(e=n.t,n=n.f):e=a,s){if(Et(t)){c&&Fe(a),t=e.o;continue}s=!1}else t.e&=-33;if(t=e.o,e.i){e=e.i;continue e}}return s}},Fe=e=>{do{let t=e.o,n=t.e;(n&48)===32&&(t.e=n|16,n&2&&Xe(t))}while(e=e.n)},fn=(e,t)=>{let n=t.a;for(;n;){if(n===e)return!0;n=n.l}return!1},W=e=>{let t=se,n=e.split(".");for(let r of n){if(t==null||!x(t,r))return;t=t[r]}return t},Oe=(e,t="")=>{let n=Array.isArray(e);if(n||K(e)){let r=n?[]:{};for(let i in e)r[i]=me(Oe(e[i],`${t+i}.`));let s=me(0);return new Proxy(r,{get(i,o){if(!(o==="toJSON"&&!x(r,o)))return n&&o in Array.prototype?(s(),r[o]):typeof o=="symbol"?r[o]:((!x(r,o)||r[o]()==null)&&(r[o]=me(""),z(t+o,""),s(s()+1)),r[o]())},set(i,o,a){let c=t+o;if(n&&o==="length"){let l=r[o]-a;if(r[o]=a,l>0){let u={};for(let f=a;f{if(e!==void 0&&t!==void 0&&xe.push([e,t]),!He&&xe.length){let n=Le(xe);xe.length=0,document.dispatchEvent(new CustomEvent(ne,{detail:n}))}},D=(e,{ifMissing:t}={})=>{N();for(let n in e)e[n]==null?t||delete se[n]:Pt(e[n],n,se,"",t);P()},S=(e,t)=>D(Le(e),t),Pt=(e,t,n,r,s)=>{if(K(e)){x(n,t)&&(K(n[t])||Array.isArray(n[t]))||(n[t]={});for(let i in e)e[i]==null?s||delete n[t][i]:Pt(e[i],i,n[t],`${r+t}.`,s)}else s&&x(n,t)||(n[t]=e)},St=e=>typeof e=="string"?RegExp(e.replace(/^\/|\/$/g,"")):e,$=({include:e=/.*/,exclude:t=/(?!)/}={},n=se)=>{let r=St(e),s=St(t),i=[],o=[[n,""]];for(;o.length;){let[a,c]=o.pop();for(let l in a){let u=c+l;K(a[l])?o.push([a[l],`${u}.`]):r.test(u)&&!s.test(u)&&i.push([u,W(u)])}}return Le(i)},se=Oe({});var Z=e=>e instanceof HTMLElement||e instanceof SVGElement||e instanceof MathMLElement;var ge=e=>e.replace(/([A-Z]+)([A-Z][a-z])/g,"$1-$2").replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase();var Ot=e=>ge(e).replace(/-/g,"_");var dn=/^(?:(?:async\s+)?function\b|(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/,ae=(e,t={})=>{let{reviveFunctionStrings:n=!1}=t;try{return n?JSON.parse(e,(r,s)=>{if(typeof s!="string")return s;let i=s.trim();if(!dn.test(i))return s;try{let o=Function(`return (${i})`)();return typeof o=="function"?o:s}catch{return s}}):JSON.parse(e)}catch{return Function(`return (${e})`)()}},Ct={camel:e=>e.replace(/-[a-z]/g,t=>t[1].toUpperCase()),snake:e=>e.replace(/-/g,"_"),pascal:e=>e[0].toUpperCase()+Ct.camel(e.slice(1))},O=(e,t,n="camel")=>{for(let r of t.get("case")||[n])e=Ct[r]?.(e)||e;return e},U=e=>`data-${e}`,tt=e=>e;var pn="https://data-star.dev/errors",he=(e,t,n={})=>{Object.assign(n,e);let r=new Error,s=Ot(t),i=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),o=JSON.stringify(n,null,2);return r.message=`${t} +More info: ${pn}/${s}?${i} +Context: ${o}`,r},ye=new Map,nt=new Map,_t=new Map,kt=new Proxy({},{get:(e,t)=>ye.get(t)?.apply,has:(e,t)=>ye.has(t),ownKeys:()=>Reflect.ownKeys(ye),set:()=>!1,deleteProperty:()=>!1}),be=new Map,ke=[],rt=new Set,ve=new Set,Ft=!1,g=e=>{ke.push(e),ke.length===1&&setTimeout(()=>{for(let n of ke)rt.add(n.name),nt.set(n.name,n);ke.length=0;let t=ve.size?[...ve]:[document.documentElement];for(let n of t)En(n,!ve.has(n));rt.clear()})},V=e=>{ye.set(e.name,e)};document.addEventListener(B,e=>{let t=_t.get(e.detail.type);t&&t.apply({error:he.bind(0,{plugin:{type:"watcher",name:t.name},element:{id:e.target.id,tag:e.target.tagName}})},e.detail.argsRaw)});var Ee=e=>{_t.set(e.name,e)},Ht=e=>{for(let t of e){let n=be.get(t);if(n&&be.delete(t))for(let r of n.values())for(let s of r.values())s()}},Dt=U("ignore"),mn=`[${Dt}]`,Vt=e=>e.hasAttribute(`${Dt}__self`)||!!e.closest(mn),De=(e,t)=>{for(let n of e)if(!Vt(n)){let r=new Set;for(let s in n.dataset){let i=s.replace(/[A-Z]/g,"-$&").toLowerCase();r.add(i),st(n,i,n.dataset[s],t)}for(let s of Array.from(n.attributes)){if(!s.name.startsWith("data-"))continue;let i=s.name.slice(5);r.has(i)||st(n,i,s.value,t)}}},gn=e=>{for(let{target:t,type:n,attributeName:r,addedNodes:s,removedNodes:i}of e)if(n==="childList"){for(let o of i)Z(o)&&(Ht([o]),Ht(o.querySelectorAll("*")));for(let o of s)Z(o)&&(De([o]),De(o.querySelectorAll("*")))}else if(n==="attributes"&&r.startsWith("data-")&&Z(t)&&!Vt(t)){let o=r.slice(5),a=tt(o);if(!a)continue;let c=t.getAttribute(r);if(c===null){let l=be.get(t);if(l){let u=l.get(a);if(u){for(let f of u.values())f();l.delete(a)}}}else st(t,o,c)}},hn=new MutationObserver(gn),yn=e=>{let[t,...n]=e.split("__"),[r,s]=t.split(/:(.+)/),i=new Map;for(let o of n){let[a,...c]=o.split(".");i.set(a,new Set(c))}return{pluginName:r,key:s,mods:i}},vn=()=>ve.has(document.documentElement),bn=()=>{Ft||!vn()||(Ft=!0,document.dispatchEvent(new Event(vt)))},En=(e=document.documentElement,t=!0)=>{Z(e)&&De([e],!0),De(e.querySelectorAll("*"),!0),t&&(hn.observe(e,{subtree:!0,childList:!0,attributes:!0}),ve.add(e),bn())};var st=(e,t,n,r)=>{let s=tt(t);if(!s)return;let{pluginName:i,key:o,mods:a}=yn(s),c=nt.get(i);if((!r||rt.has(i))&&!!c){let u={el:e,rawKey:s,mods:a,error:he.bind(0,{plugin:{type:"attribute",name:c.name},element:{id:e.id,tag:e.tagName},expression:{rawKey:s,key:o,value:n}}),key:o,value:n,loadedPluginNames:{actions:new Set(ye.keys()),attributes:new Set(nt.keys())},rx:void 0},f=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.key)||"allowed",h=c.requirement&&(typeof c.requirement=="string"?c.requirement:c.requirement.value)||"allowed",d=o!=null&&o!=="",p=n!=null&&n!=="";if(d){if(f==="denied")throw u.error("KeyNotAllowed")}else if(f==="must")throw u.error("KeyRequired");if(p){if(h==="denied")throw u.error("ValueNotAllowed")}else if(h==="must")throw u.error("ValueRequired");if(f==="exclusive"||h==="exclusive"){if(d&&p)throw u.error("KeyAndValueProvided");if(!d&&!p)throw u.error("KeyOrValueRequired")}let m=new Map;if(p){let v;u.rx=(...F)=>(v||(v=Sn(n,{returnsValue:c.returnsValue,argNames:c.argNames,cleanups:m})),v(e,...F))}let y=c.apply(u);y&&m.set("attribute",y);let T=be.get(e);if(T){let v=T.get(s);if(v)for(let F of v.values())F()}else T=new Map,be.set(e,T);T.set(s,m)}},Sn=(e,{returnsValue:t=!1,argNames:n=[],cleanups:r=new Map}={})=>{let s="";if(t){let c=/(\/(\\\/|[^/])*\/|"(\\"|[^"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|\(\s*((function)\s*\(\s*\)|(\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/gm,l=e.trim().match(c);if(l){let u=l.length-1,f=l[u].trim();f.startsWith("return")||(l[u]=`return (${f});`),s=l.join(`; +`)}}else s=e.trim();let i=new Map,o=RegExp(`(?:${Ue})(.*?)(?:${Je})`,"gm"),a=0;for(let c of s.matchAll(o)){let l=c[1],u=`__escaped${a++}`;i.set(u,l),s=s.replace(Ue+l+Je,u)}s=s.replace(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\$]|\$(?!\{))*`)|\$\{([^{}]*)\}|\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(c,l,u,f)=>l?c:u!==void 0?`\${${u.replace(/\$([a-zA-Z_\d]\w*(?:[.-]\w+)*)/g,(h,d)=>d.split(".").reduce((p,m)=>`${p}['${m}']`,"$"))}}`:f.split(".").reduce((h,d)=>`${h}['${d}']`,"$")),s=s.replaceAll(/@([A-Za-z_$][\w$]*)\(/g,'__action("$1",evt,');for(let[c,l]of i)s=s.replace(c,l);try{let c=Function("el","$","__action","evt",...n,s);return(l,...u)=>{let f=(h,d,...p)=>{let m=he.bind(0,{plugin:{type:"action",name:h},element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e}}),y=kt[h];if(y)return y({el:l,evt:d,error:m,cleanups:r},...p);throw m("UndefinedAction")};try{return c(l,se,f,void 0,...u)}catch(h){throw console.error(h),he({element:{id:l.id,tag:l.tagName},expression:{fnContent:s,value:e},error:h.message},"ExecuteExpression")}}}catch(c){throw console.error(c),he({expression:{fnContent:s,value:e},error:c.message},"GenerateExpression")}};V({name:"peek",apply(e,t){_();try{return t()}finally{k()}}});V({name:"setAll",apply(e,t,n){_();let r=$(n);re(r,()=>t),D(r),k()}});V({name:"toggleAll",apply(e,t){_();let n=$(t);re(n,r=>!r),D(n),k()}});var It=new WeakMap,it=e=>!["GET","DELETE"].includes(e),Se=(e,t,n=!0)=>V({name:e,apply:async({el:r,evt:s,error:i,cleanups:o},a,{selector:c,headers:l,contentType:u="json",filterSignals:{include:f=/.*/,exclude:h=/(^|\.)_/}={},openWhenHidden:d=n,payload:p,requestCancellation:m="auto",retry:y="auto",retryInterval:T=1e3,retryScaler:v=2,retryMaxWait:F=3e4,retryMaxCount:Re=10}={})=>{let X=m instanceof AbortController?m:new AbortController;(m==="auto"||m==="cleanup")&&(It.get(r)?.abort(),It.set(r,X)),m==="cleanup"&&(o.get(`@${e}`)?.(),o.set(`@${e}`,async()=>{X.abort(),await Promise.resolve()}));let ee=()=>{};try{if(!a?.length)throw i("FetchNoUrlProvided",{action:V});let fe={Accept:"text/event-stream, text/html, application/json","Datastar-Request":!0};u==="json"&&it(t)&&(fe["Content-Type"]="application/json");let q=Object.assign({},fe,l),C={input:"",method:t,headers:q,openWhenHidden:d,retry:y,retryInterval:T,retryScaler:v,retryMaxWait:F,retryMaxCount:Re,signal:X.signal,onopen:async b=>{b.status>=400&&ie(Tn,r,{status:b.status.toString()})},onmessage:b=>{if(!b.event.startsWith("datastar"))return;let J=b.event,w={};for(let E of b.data.split(` +`)){let A=E.indexOf(" "),H=E.slice(0,A),M=E.slice(A+1);(w[H]||=[]).push(M)}let L=Object.fromEntries(Object.entries(w).map(([E,A])=>[E,A.join(` +`)]));ie(J,r,L)},onerror:b=>{if($t(b))throw b("FetchExpectedTextEventStream",{url:a});b&&(console.error(b.message),ie(An,r,{message:b.message}))}},qe=()=>{let b=new URL(a,document.baseURI),J=new URLSearchParams(b.search);if(u==="json"){_();let w=p!==void 0?p:$({include:f,exclude:h});k();let L=JSON.stringify(w);it(t)?C.body=L:J.set("datastar",L)}else if(u==="form"){let w=c?document.querySelector(c):r.closest("form");if(!w)throw i("FetchFormNotFound",{action:V,selector:c});if(!w.noValidate&&!w.checkValidity()){w.reportValidity();return}let L=new FormData(w),E=r;if(r===w&&s instanceof SubmitEvent)E=s.submitter;else{let M=de=>de.preventDefault();w.addEventListener("submit",M),ee=()=>{w.removeEventListener("submit",M)}}if(E instanceof HTMLButtonElement||E instanceof HTMLInputElement&&E.type==="submit"){let M=E.getAttribute("name");M&&L.append(M,E.value)}let A=w.getAttribute("enctype")==="multipart/form-data";A||(q["Content-Type"]="application/x-www-form-urlencoded");let H=new URLSearchParams(L);if(it(t))A?C.body=L:C.body=H;else for(let[M,de]of H)J.append(M,de)}else throw i("FetchInvalidContentType",{action:V,contentType:u});return b.search=J.toString(),C.input=b.toString(),C};ie(ot,r,{});try{await Nn(r,qe)}catch(b){if(!$t(b))throw i("FetchFailed",{method:t,url:a,error:b.message})}}finally{ie(at,r,{}),ee(),o.delete(`@${e}`)}}});Se("get","GET",!1);Se("patch","PATCH");Se("post","POST");Se("put","PUT");Se("delete","DELETE");var ot="started",at="finished",Tn="error",An="retrying",Rn="retries-failed",ie=(e,t,n)=>document.dispatchEvent(new CustomEvent(B,{detail:{type:e,el:t,argsRaw:n}})),$t=e=>`${e}`.includes("text/event-stream"),wn=async(e,t)=>{let n=e.getReader(),r=await n.read();for(;!r.done;)t(r.value),r=await n.read()},Mn=e=>{let t,n,r,s=!1;return i=>{t?t=xn(t,i):(t=i,n=0,r=-1);let o=t.length,a=0;for(;n{let r=qt(),s=new TextDecoder;return(i,o)=>{if(!i.length)n?.(r),r=qt();else if(o>0){let a=s.decode(i.subarray(0,o)),c=o+(i[o+1]===32?2:1),l=s.decode(i.subarray(c));switch(a){case"data":r.data=r.data?`${r.data} +${l}`:l;break;case"event":r.event=l;break;case"id":e(r.id=l);break;case"retry":{let u=+l;Number.isNaN(u)||t(r.retry=u);break}}}}},xn=(e,t)=>{let n=new Uint8Array(e.length+t.length);return n.set(e),n.set(t,e.length),n},qt=()=>({data:"",event:"",id:"",retry:void 0}),Nn=(e,t)=>new Promise((n,r)=>{let s=t();if(!s)return;let{input:i,signal:o,headers:a,onopen:c,onmessage:l,onclose:u,onerror:f,openWhenHidden:h,fetch:d,retry:p="auto",retryInterval:m=1e3,retryScaler:y=2,retryMaxWait:T=3e4,retryMaxCount:v=10,responseOverrides:F,...Re}=s,X={...a},ee,fe=()=>{if(ee.abort(),!document.hidden){let E=t();if(!E)return;i=E.input,Re.body=E.body,L()}};h||document.addEventListener("visibilitychange",fe);let q,C=()=>{document.removeEventListener("visibilitychange",fe),clearTimeout(q),ee.abort()};o?.addEventListener("abort",()=>{C(),n()});let qe=d||window.fetch,b=c||(()=>{}),J=0,w=m,L=async()=>{ee=new AbortController;let E=ee.signal;try{let A=await qe(i,{...Re,headers:X,signal:E});await b(A);let H=async(G,pe,Be,we,...an)=>{let ht={[Be]:await pe.text()};for(let je of an){let We=pe.headers.get(`datastar-${ge(je)}`);if(we){let Me=we[je];Me&&(We=typeof Me=="string"?Me:JSON.stringify(Me))}We&&(ht[je]=We)}ie(G,e,ht),C(),n()},M=A.status,de=M===204,gt=M>=300&&M<400,on=M>=400&&M<600;if(M!==200){if(u?.(),p!=="never"&&!de&&!gt&&(p==="always"||p==="error"&&on)){clearTimeout(q),q=setTimeout(L,m);return}C(),n();return}J=0,m=w;let Ge=A.headers.get("Content-Type");if(Ge?.includes("text/html"))return await H("datastar-patch-elements",A,"elements",F,"selector","mode","namespace","useViewTransition");if(Ge?.includes("application/json"))return await H("datastar-patch-signals",A,"signals",F,"onlyIfMissing");if(Ge?.includes("text/javascript")){let G=document.createElement("script"),pe=A.headers.get("datastar-script-attributes");if(pe)for(let[Be,we]of Object.entries(JSON.parse(pe)))G.setAttribute(Be,we);G.textContent=await A.text(),document.head.appendChild(G),C();return}if(await wn(A.body,Mn(Ln(G=>{G?X["last-event-id"]=G:delete X["last-event-id"]},G=>{w=m=G},l))),u?.(),p==="always"&&!gt){clearTimeout(q),q=setTimeout(L,m);return}C(),n()}catch(A){if(!E.aborted)try{let H=f?.(A)||m;clearTimeout(q),q=setTimeout(L,H),m=Math.min(m*y,T),++J>=v?(ie(Rn,e,{}),C(),r("Max retries reached.")):console.error(`Datastar failed to reach ${i.toString()} retrying in ${H}ms.`)}catch(H){C(),r(H)}}};L()});g({name:"attr",requirement:{value:"must"},returnsValue:!0,apply({el:e,key:t,rx:n}){let r=(a,c)=>{c===""||c===!0?e.setAttribute(a,""):c===!1||c==null?e.removeAttribute(a):typeof c=="string"?e.setAttribute(a,c):typeof c=="function"?e.setAttribute(a,c.toString()):e.setAttribute(a,JSON.stringify(c,(l,u)=>typeof u=="function"?u.toString():u))},s=t?()=>{i.disconnect();let a=n();r(t,a),i.observe(e,{attributeFilter:[t]})}:()=>{i.disconnect();let a=n(),c=Object.keys(a);for(let l of c)r(l,a[l]);i.observe(e,{attributeFilter:c})},i=new MutationObserver(s),o=R(s);return()=>{i.disconnect(),o()}}});var Ve=(e,...t)=>({get:n=>n[e],set:(n,r)=>{n[e]=r},events:t}),Gt=(e,...t)=>({get:n=>n.getAttribute(e),set:(n,r)=>{n.setAttribute(e,`${r}`)},events:t}),ct=(e=!1,...t)=>({get:(n,r)=>r==="string"||e&&r==="undefined"?n.value:+n.value,set:(n,r)=>{n.value=`${r}`},events:t}),Pn=/^data:(?[^;]+);base64,(?.*)$/,lt=Symbol("empty"),Ie=U("bind"),Bt=(e,t,n,r,s,i)=>{if(i===void 0&&e instanceof HTMLInputElement&&e.type==="radio"){let u=t||n,f=[...document.querySelectorAll(`[${Ie}\\:${CSS.escape(u)}],[${Ie}="${CSS.escape(u)}"]`)].find(h=>h instanceof HTMLInputElement&&h.checked);f&&S([[r,f.value]],{ifMissing:!0})}if(!Array.isArray(i)||e instanceof HTMLSelectElement&&e.multiple)return S([[r,s.get(e,typeof i)]],{ifMissing:!0}),r;let o=t||n,a=document.querySelectorAll(`[${Ie}\\:${CSS.escape(o)}],[${Ie}="${CSS.escape(o)}"]`),c=[],l=0;for(let u of a){if(c.push([`${r}.${l}`,s.get(u,typeof(x(i,l)?i[l]:void 0))]),e===u)break;l++}return S(c,{ifMissing:!0}),`${r}.${l}`};g({name:"bind",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r,error:s}){let i=t!=null?O(t,n):r,o=n.get("prop"),a=n.get("event"),c=null;if(o){let d=[...o][0];if(!d?.length)throw s("BindPropNameMissing");if(!a?.size)throw s("BindEventRequired");c=Ve(d,...a)}else if(a)throw s("BindPropRequiredWhenEventProvided");if(c){let d=W(i),p=Bt(e,t,r,i,c,d),m=()=>{let T=W(p);if(T!=null){let v=c?.get(e,typeof T);v!==lt&&S([[p,v]])}};for(let T of c?.events??[])e.addEventListener(T,m);e.addEventListener(te,m);let y=R(()=>{c?.set(e,W(p))});return()=>{y();for(let T of c?.events??[])e.removeEventListener(T,m);e.removeEventListener(te,m)}}if(e instanceof HTMLInputElement)switch(e.type){case"range":case"number":c=ct(!1,"input");break;case"checkbox":c={get:(d,p)=>d.value!=="on"?p==="boolean"?d.checked:d.checked?d.value:"":p==="string"?d.checked?d.value:"":d.checked,set:(d,p)=>{d.checked=typeof p=="string"?p===d.value:p},events:["change"]};break;case"radio":e.getAttribute("name")?.length||e.setAttribute("name",i),c={get:(d,p)=>d.checked?p==="number"?+d.value:d.value:lt,set:(d,p)=>{d.checked=p===(typeof p=="number"?+d.value:d.value)},events:["change"]};break;case"file":{let d=()=>{let p=[...e.files||[]],m=[];Promise.all(p.map(y=>new Promise(T=>{let v=new FileReader;v.onload=()=>{if(typeof v.result!="string")throw s("InvalidFileResultType",{resultType:typeof v.result});let F=v.result.match(Pn);if(!F?.groups)throw s("InvalidDataUri",{result:v.result});m.push({name:y.name,contents:F.groups.contents,mime:F.groups.mime})},v.onloadend=()=>T(),v.readAsDataURL(y)}))).then(()=>{S([[i,m]])})};return e.addEventListener("change",d),()=>{e.removeEventListener("change",d)}}default:c=ct(!0,"input")}else if(e instanceof HTMLSelectElement&&e.multiple){let d=new Map;c={get:p=>[...p.selectedOptions].map(m=>{let y=d.get(m.value);return y==="string"||y==null?m.value:+m.value}),set:(p,m)=>{for(let y of p.options)m.includes(y.value)?(d.set(y.value,"string"),y.selected=!0):m.includes(+y.value)?(d.set(y.value,"number"),y.selected=!0):y.selected=!1},events:["change"]}}else e instanceof HTMLSelectElement?c=ct(!1,"change"):e instanceof HTMLTextAreaElement?c=Ve("value","input"):e instanceof HTMLElement&&e.tagName.includes("-")?c="value"in e?Ve("value","input","change"):Gt("value","input","change"):e instanceof HTMLElement&&"value"in e?c=Ve("value","change"):c=Gt("value","change");if(!c)throw s("InvalidBindAdapter");let l=W(i),u=Bt(e,t,r,i,c,l),f=()=>{let d=W(u);if(d!=null){let p=c.get(e,typeof d);p!==lt&&S([[u,p]])}};for(let d of c.events)e.addEventListener(d,f);e.addEventListener(te,f);let h=R(()=>{c.set(e,W(u))});return()=>{h();for(let d of c.events)e.removeEventListener(d,f);e.removeEventListener(te,f)}}});g({name:"class",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,mods:n,rx:r}){e&&=O(e,n,"kebab");let s,i=()=>{o.disconnect(),s=e?{[e]:r()}:r();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);if(s[c])for(let u of l)t.classList.contains(u)||t.classList.add(u);else for(let u of l)t.classList.contains(u)&&t.classList.remove(u)}o.observe(t,{attributeFilter:["class"]})},o=new MutationObserver(i),a=R(i);return()=>{o.disconnect(),a();for(let c in s){let l=c.split(/\s+/).filter(u=>u.length>0);for(let u of l)t.classList.remove(u)}}}});g({name:"computed",requirement:{value:"must"},returnsValue:!0,apply({key:e,mods:t,rx:n,error:r}){if(e)S([[O(e,t),_e(n)]]);else{let s=Object.assign({},n());re(s,i=>{if(typeof i=="function")return _e(i);throw r("ComputedExpectedFunction")}),D(s)}}});g({name:"effect",requirement:{key:"denied",value:"must"},apply:({rx:e})=>R(e)});g({name:"indicator",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?O(t,n):r,i=0;S([[s,!1]]);let o=a=>{let{type:c,el:l}=a.detail;if(l===e)switch(c){case ot:i++,S([[s,!0]]);break;case at:i=Math.max(0,i-1),S([[s,i>0]]);break}};return document.addEventListener(B,o),()=>{i=0,S([[s,!1]]),document.removeEventListener(B,o)}}});var Q=e=>{if(!e||e.size<=0)return 0;for(let t of e){if(t.endsWith("ms"))return+t.replace("ms","");if(t.endsWith("s"))return+t.replace("s","")*1e3;try{return Number.parseFloat(t)}catch{}}return 0},oe=(e,t,n=!1)=>e?e.has(t.toLowerCase()):n,jt=(e,t="")=>{if(e&&e.size>0)for(let n of e)return n;return t};var ut=(e,t)=>(...n)=>{setTimeout(()=>{e(...n)},t)},Wt=(e,t,n=!0,r=!1,s=!1)=>{let i=null,o=0;return(...a)=>{n&&!o?(e(...a),i=null):i=a,(!o||s)&&(o&&clearTimeout(o),o=setTimeout(()=>{r&&i!==null&&e(...i),i=null,o=0},t))}},ce=(e,t)=>{let n=t.get("delay");if(n){let i=Q(n);e=ut(e,i)}let r=t.get("debounce");if(r){let i=Q(r),o=oe(r,"leading",!1),a=!oe(r,"notrailing",!1);e=Wt(e,i,o,a,!0)}let s=t.get("throttle");if(s){let i=Q(s),o=!oe(s,"noleading",!1),a=oe(s,"trailing",!1);e=Wt(e,i,o,a)}return e};var ft=!!document.startViewTransition,Y=(e,t)=>{if(t.has("viewtransition")&&ft){let n=e;e=(...r)=>document.startViewTransition(()=>n(...r))}return e};g({name:"init",requirement:{key:"denied",value:"must"},apply({rx:e,mods:t}){let n=()=>{N(),e(),P()};n=Y(n,t);let r=0,s=t.get("delay");s&&(r=Q(s),r>0&&(n=ut(n,r))),n()}});g({name:"json-signals",requirement:{key:"denied"},apply({el:e,value:t,mods:n}){let r=n.has("terse")?0:2,s={};t&&(s=ae(t));let i=()=>{o.disconnect(),e.textContent=JSON.stringify($(s),null,r),o.observe(e,{childList:!0,characterData:!0,subtree:!0})},o=new MutationObserver(i),a=R(i);return()=>{o.disconnect(),a()}}});g({name:"on",requirement:"must",argNames:["evt"],apply({el:e,key:t,mods:n,rx:r}){let s=e;n.has("window")?s=window:n.has("document")&&(s=document);let i=l=>{N(),r(l),P()};i=Y(i,n),i=ce(i,n);let o=O(t,n,"kebab"),a={capture:n.has("capture"),passive:n.has("passive"),once:n.has("once")};if(n.has("outside")){s=document;let l=i;i=u=>{e.contains(u?.target)||l(u)}}(o===B||o===ne)&&(s=document);let c=l=>{l&&(n.has("prevent")&&l.preventDefault(),n.has("stop")&&l.stopPropagation(),e instanceof HTMLFormElement&&o==="submit"&&l.preventDefault()),i(l)};return s.addEventListener(o,c,a),()=>{s.removeEventListener(o,c,a)}}});var Ut=(e,t,n)=>Math.max(t,Math.min(n,e));var dt=new WeakSet;g({name:"on-intersect",requirement:{key:"denied",value:"must"},apply({el:e,mods:t,rx:n}){let r=()=>{N(),n(),P()};r=Y(r,t),r=ce(r,t);let s={threshold:0};if(t.has("full"))s.threshold=1;else if(t.has("half"))s.threshold=.5;else{let a=t.get("threshold");a&&(s.threshold=Ut(Number(jt(a)),0,100)/100)}let i=t.has("exit"),o=new IntersectionObserver(a=>{for(let c of a)c.isIntersecting!==i&&(r(),o&&dt.has(e)&&o.disconnect())},s);return o.observe(e),t.has("once")&&dt.add(e),()=>{t.has("once")||dt.delete(e),o&&(o.disconnect(),o=null)}}});g({name:"on-interval",requirement:{key:"denied",value:"must"},apply({mods:e,rx:t}){let n=()=>{N(),t(),P()};n=Y(n,e);let r=1e3,s=e.get("duration");s&&(r=Q(s),oe(s,"leading",!1)&&n());let i=setInterval(n,r);return()=>{clearInterval(i)}}});g({name:"on-signal-patch",requirement:{value:"must"},argNames:["patch"],returnsValue:!0,apply({el:e,key:t,mods:n,rx:r,error:s}){if(t&&t!=="filter")throw s("KeyNotAllowed");let i=U(`${this.name}-filter`),o=e.getAttribute(i),a={};o&&(a=ae(o));let c=!1,l=ce(u=>{if(c)return;let f=$(a,u.detail);if(!bt(f)){c=!0,N();try{r(f)}finally{P(),c=!1}}},n);return document.addEventListener(ne,l),()=>{document.removeEventListener(ne,l)}}});g({name:"ref",requirement:"exclusive",apply({el:e,key:t,mods:n,value:r}){let s=t!=null?O(t,n):r;S([[s,e]])}});var Jt="none",Kt="display";g({name:"show",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),t()?e.style.display===Jt&&e.style.removeProperty(Kt):e.style.setProperty(Kt,Jt),r.observe(e,{attributeFilter:["style"]})},r=new MutationObserver(n),s=R(n);return()=>{r.disconnect(),s()}}});g({name:"signals",returnsValue:!0,apply({key:e,mods:t,rx:n}){let r=t.has("ifmissing");if(e){e=O(e,t);let s=n?.();S([[e,s]],{ifMissing:r})}else{let s=Object.assign({},n?.());D(s,{ifMissing:r})}}});g({name:"style",requirement:{value:"must"},returnsValue:!0,apply({key:e,el:t,rx:n}){let{style:r}=t,s=new Map,i=(l,u)=>{let f=s.get(l);!u&&u!==0?f!==void 0&&(f?r.setProperty(l,f):r.removeProperty(l)):(f===void 0&&s.set(l,r.getPropertyValue(l)),r.setProperty(l,String(u)))},o=()=>{if(a.disconnect(),e)i(e,n());else{let l=n();for(let[u,f]of s)u in l||(f?r.setProperty(u,f):r.removeProperty(u));for(let u in l)i(ge(u),l[u])}a.observe(t,{attributeFilter:["style"]})},a=new MutationObserver(o),c=R(o);return()=>{a.disconnect(),c();for(let[l,u]of s)u?r.setProperty(l,u):r.removeProperty(l)}}});g({name:"text",requirement:{key:"denied",value:"must"},returnsValue:!0,apply({el:e,rx:t}){let n=()=>{r.disconnect(),e.textContent=`${t()}`,r.observe(e,{childList:!0,characterData:!0,subtree:!0})},r=new MutationObserver(n),s=R(n);return()=>{r.disconnect(),s()}}});var zt=(e,t)=>e.includes(t),On=["remove","outer","inner","replace","prepend","append","before","after"],Cn=["html","svg","mathml"];Ee({name:"datastar-patch-elements",apply(e,t){let n=typeof t.selector=="string"?t.selector:"",r=typeof t.mode=="string"?t.mode:"outer",s=typeof t.namespace=="string"?t.namespace:"html",i=typeof t.useViewTransition=="string"?t.useViewTransition:"",o=t.elements;if(!zt(On,r))throw e.error("PatchElementsInvalidMode",{mode:r});if(!n&&r!=="outer"&&r!=="replace")throw e.error("PatchElementsExpectedSelector");if(!zt(Cn,s))throw e.error("PatchElementsInvalidNamespace",{namespace:s});let a={selector:n,mode:r,namespace:s,useViewTransition:i.trim()==="true",elements:o};ft&&a.useViewTransition?document.startViewTransition(()=>Zt(e,a)):Zt(e,a)}});var Zt=({error:e},{selector:t,mode:n,namespace:r,elements:s})=>{let i=document.createDocumentFragment(),o=typeof s!="string"&&!!s;if(typeof s=="string"){let a=s.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,""),c=/<\/html>/.test(a),l=/<\/head>/.test(a),u=/<\/body>/.test(a),f=r==="svg"?"svg":r==="mathml"?"math":"",h=f?`<${f}>${s}`:s,d=new DOMParser().parseFromString(c||l||u?s:``,"text/html");if(c)i.appendChild(d.documentElement);else if(l&&u)i.appendChild(d.head),i.appendChild(d.body);else if(l)i.appendChild(d.head);else if(u)i.appendChild(d.body);else if(f){let p=d.querySelector("template").content.querySelector(f);for(let m of p.childNodes)i.appendChild(m)}else i=d.querySelector("template").content}else s&&(s instanceof DocumentFragment?i=s:s instanceof Element&&i.appendChild(s));if(!t&&(n==="outer"||n==="replace")){let a=Array.from(i.children);for(let c of a){let l;if(c instanceof HTMLHtmlElement)l=document.documentElement;else if(c instanceof HTMLBodyElement)l=document.body;else if(c instanceof HTMLHeadElement)l=document.head;else if(l=document.getElementById(c.id),!l){console.warn(e("PatchElementsNoTargetsFound"),{element:{id:c.id}});continue}Yt(n,c,[l],o)}}else{let a=document.querySelectorAll(t);if(!a.length){console.warn(e("PatchElementsNoTargetsFound"),{selector:t});return}let c=o&&n!=="remove"?[a[0]]:a;Yt(n,i,c,o)}},mt=new WeakSet;for(let e of document.querySelectorAll("script"))mt.add(e);var nn=e=>{let t=e instanceof HTMLScriptElement?[e]:e.querySelectorAll("script");for(let n of t)if(!mt.has(n)){let r=document.createElement("script");for(let{name:s,value:i}of n.attributes)r.setAttribute(s,i);r.text=n.text,n.replaceWith(r),mt.add(r)}},Qt=(e,t,n,r)=>{let s=!1;for(let i of e){if(r&&s)break;let o=r?t:t.cloneNode(!0);nn(o),i[n](o),s=!0}},Yt=(e,t,n,r)=>{switch(e){case"remove":for(let s of n)s.remove();break;case"outer":case"inner":{let s=!1;for(let i of n){if(r&&s)break;let o=r?t:t.cloneNode(!0);Hn(i,o,e),nn(i);let a=i.closest("[data-scope-children]");a&&a.dispatchEvent(new CustomEvent(Ke,{bubbles:!1})),s=!0}}break;case"replace":Qt(n,t,"replaceWith",r);break;case"prepend":case"append":case"before":case"after":Qt(n,t,e,r)}},I=new Map,ue=new Set,le=new Map,Te=new Set,$e=document.createElement("div");$e.hidden=!0;var Ae=U("ignore-morph"),Fn=`[${Ae}]`,Hn=(e,t,n="outer")=>{if(Z(e)&&Z(t)&&e.hasAttribute(Ae)&&t.hasAttribute(Ae)||e.parentElement?.closest(Fn))return;let r=document.createElement("div");r.append(t),document.body.insertAdjacentElement("afterend",$e);let s=e.querySelectorAll("[id]");for(let{id:a,tagName:c}of s)le.has(a)?Te.add(a):le.set(a,c);e instanceof Element&&e.id&&(le.has(e.id)?Te.add(e.id):le.set(e.id,e.tagName)),ue.clear();let i=r.querySelectorAll("[id]");for(let{id:a,tagName:c}of i)ue.has(a)?Te.add(a):le.get(a)===c&&ue.add(a);for(let a of Te)ue.delete(a);le.clear(),Te.clear(),I.clear();let o=n==="outer"?e.parentElement:e;tn(o,s),tn(r,i),rn(o,r,n==="outer"?e:null,e.nextSibling),$e.remove()},rn=(e,t,n=null,r=null)=>{e instanceof HTMLTemplateElement&&t instanceof HTMLTemplateElement&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let i=_n(s,n,r);if(i){if(i!==n){let o=n;for(;o&&o!==i;){let a=o;o=o.nextSibling,en(a)}}pt(i,s),n=i.nextSibling;continue}}if(s instanceof Element&&ue.has(s.id)){let i=document.getElementById(s.id),o=i;for(;o=o.parentNode;){let a=I.get(o);a&&(a.delete(s.id),a.size||I.delete(o))}sn(e,i,n),pt(i,s),n=i.nextSibling;continue}if(I.has(s)){let i=s.namespaceURI,o=s.tagName,a=i&&i!=="http://www.w3.org/1999/xhtml"?document.createElementNS(i,o):document.createElement(o);e.insertBefore(a,n),pt(a,s),n=a.nextSibling}else{let i=document.importNode(s,!0);e.insertBefore(i,n),n=i.nextSibling}}for(;n&&n!==r;){let s=n;n=n.nextSibling,en(s)}},_n=(e,t,n)=>{let r=null,s=e.nextSibling,i=0,o=0,a=I.get(e)?.size||0,c=t;for(;c&&c!==n;){if(Xt(c,e)){let l=!1,u=I.get(c),f=I.get(e);if(f&&u){for(let h of u)if(f.has(h)){l=!0;break}}if(l)return c;if(!r&&!I.has(c)){if(!a)return c;r=c}}if(o+=I.get(c)?.size||0,o>a)break;r===null&&s&&Xt(c,s)&&(i++,s=s.nextSibling,i>=2&&(r=void 0)),c=c.nextSibling}return r||null},Xt=(e,t)=>e.nodeType===t.nodeType&&e.tagName===t.tagName&&(!e.id||e.id===t.id),en=e=>{I.has(e)?sn($e,e,null):e.parentNode?.removeChild(e)},sn=(e,t,n)=>{if("moveBefore"in e){e.moveBefore(t,n);return}e.insertBefore(t,n)},kn=U("preserve-attr"),pt=(e,t)=>{let n=t.nodeType;if(n===1){let r=e,s=t,i=r.hasAttribute("data-scope-children");if(r.hasAttribute(Ae)&&s.hasAttribute(Ae))return e;let o=(t.getAttribute(kn)??"").split(" "),a=(l,u,f)=>{let h=u.hasAttribute(f);return l.hasAttribute(f)!==h&&!o.includes(f)?(l[f]=h,!0):!1},c=!1;if(r instanceof HTMLInputElement&&s instanceof HTMLInputElement&&s.type!=="file"){let l=s.getAttribute("value");r.getAttribute("value")!==l&&!o.includes("value")&&(r.value=l??"",c=!0),c=a(r,s,"checked")||c,a(r,s,"disabled")}else if(r instanceof HTMLTextAreaElement&&s instanceof HTMLTextAreaElement){let l=s.value;r.defaultValue!==l&&(r.value=l,c=!0)}else r instanceof HTMLOptionElement&&s instanceof HTMLOptionElement&&(c=a(r,s,"selected")||c);for(let{name:l,value:u}of s.attributes)r.getAttribute(l)!==u&&!o.includes(l)&&r.setAttribute(l,u);for(let{name:l}of Array.from(r.attributes))!s.hasAttribute(l)&&!o.includes(l)&&r.removeAttribute(l);c&&(r instanceof HTMLOptionElement?r.closest("select"):r)?.dispatchEvent(new Event(te,{bubbles:!0})),i&&!r.hasAttribute("data-scope-children")&&r.setAttribute("data-scope-children",""),r instanceof HTMLTemplateElement&&s instanceof HTMLTemplateElement?r.innerHTML=s.innerHTML:r.isEqualNode(s)||rn(r,s),i&&r.dispatchEvent(new CustomEvent(Ke,{bubbles:!1}))}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),e},tn=(e,t)=>{for(let n of t)if(ue.has(n.id)){let r=n;for(;r&&r!==e;){let s=I.get(r);s||(s=new Set,I.set(r,s)),s.add(n.id),r=r.parentElement}}};Ee({name:"datastar-patch-signals",apply({error:e},{signals:t,onlyIfMissing:n}){if(typeof t!="string")throw e("PatchSignalsExpectedSignals");let r=typeof n=="string"&&n.trim()==="true";D(ae(t),{ifMissing:r})}});export{V as action,kt as actions,g as attribute,N as beginBatch,_e as computed,R as effect,P as endBatch,$ as filtered,W as getPath,D as mergePatch,S as mergePaths,se as root,me as signal,_ as startPeeking,k as stopPeeking,Ee as watcher}; +//# sourceMappingURL=datastar.js.map diff --git a/rebar.config b/rebar.config index a60e960..fb88e18 100644 --- a/rebar.config +++ b/rebar.config @@ -1,9 +1,14 @@ {erl_opts, [debug_info]}. {deps, [ - nova, - {arizona_core, {git, "https://github.com/novaframework/arizona_core.git", {branch, "main"}}}, - {arizona_nova, {git, "https://github.com/novaframework/arizona_nova.git", {branch, "main"}}} + %% Nova + datastar are pre-release; pin immutable SHAs (the streaming + %% return-handler pattern depends on this Nova revision - see nova#387). + {nova, + {git, "https://github.com/novaframework/nova.git", + {ref, "9ee7e21ef5f82c9c8b71b0ff6bf0ace08d8b72a3"}}}, + {datastar, + {git, "https://github.com/Taure/datastar.git", + {ref, "fcc11f399294b305620a7c99ed633b898ce3889b"}}} ]}. {project_plugins, [ @@ -22,7 +27,10 @@ {xref_checks, [ undefined_function_calls, - undefined_functions + undefined_functions, + locals_not_used, + deprecated_function_calls, + deprecated_functions ]}. {xref_ignores, [ @@ -32,18 +40,10 @@ {kura_schema, indexes, 1} ]}. -{dialyzer, [ - {plt_apps, all_deps}, - {exclude_mods, [ - nova_liveboard_layout, - nova_liveboard_system_view, - nova_liveboard_processes_view, - nova_liveboard_ets_view, - nova_liveboard_apps_view, - nova_liveboard_ports_view, - nova_liveboard_sup_view, - nova_liveboard_metrics_view, - nova_liveboard_database_view, - nova_liveboard_schemas_view - ]} +{dialyzer, [{plt_apps, all_deps}]}. + +{overrides, [ + %% redbug (pulled transitively via nova) uses the deprecated `catch`, + %% which OTP 29 promotes to an error under warnings_as_errors. + {override, redbug, [{erl_opts, [debug_info, nowarn_deprecated_catch]}]} ]}. diff --git a/rebar.lock b/rebar.lock index e17baff..69132d7 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,50 +1,34 @@ {"1.2.0", -[{<<"arizona_core">>, - {git,"https://github.com/novaframework/arizona_core.git", - {ref,"b0dcb87f9e3e64c7ddd51a5e4f6ca24c880cf0c4"}}, +[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.15.0">>},1}, + {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.1">>},2}, + {<<"datastar">>, + {git,"https://github.com/Taure/datastar.git", + {ref,"fcc11f399294b305620a7c99ed633b898ce3889b"}}, 0}, - {<<"arizona_nova">>, - {git,"https://github.com/novaframework/arizona_nova.git", - {ref,"44e58e46e14b8e1ad9a2e48b637154b0a630ad6f"}}, - 0}, - {<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},1}, - {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},2}, - {<<"eqwalizer_support">>, - {git_subdir,"https://github.com/whatsapp/eqwalizer.git", - {ref,"80d961978515425b7c897f32da31941cb180d2eb"}, - "eqwalizer_support"}, - 1}, {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},1}, - {<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.4.0">>},1}, - {<<"markdown">>, - {git_subdir,"https://github.com/arizona-framework/erlang-markdown.git", - {ref,"c59493c94aa8dbc105a739b8d1e7a9ccf5c2b338"}, - "apps/markdown"}, - 1}, - {<<"nova">>,{pkg,<<"nova">>,<<"0.13.7">>},0}, + {<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.11.2">>},1}, + {<<"nova">>, + {git,"https://github.com/novaframework/nova.git", + {ref,"9ee7e21ef5f82c9c8b71b0ff6bf0ace08d8b72a3"}}, + 0}, {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},2}, - {<<"redbug">>,{pkg,<<"redbug">>,<<"2.1.0">>},2}, {<<"routing_tree">>,{pkg,<<"routing_tree">>,<<"1.0.11">>},1}, {<<"thoas">>,{pkg,<<"thoas">>,<<"1.2.1">>},1}]}. [ {pkg_hash,[ - {<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>}, - {<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>}, + {<<"cowboy">>, <<"9CFE86ED7117BF045E10ADBEDB0170AF7BE57F2A3637E7BE143433D8DD267396">>}, + {<<"cowlib">>, <<"318D385D55F657E9A5005838C4E426E13DCD724A691438384B6165A69687E531">>}, {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>}, - {<<"jhn_stdlib">>, <<"FAC6F19B35351278F1CB156E23A5B2A6047A9DD5AB1FD9E1189A7918006DF7ED">>}, - {<<"nova">>, <<"C37A2161EA1EE643635739282D1DF9880D20D9521B6AD3ABE6A71C6A6867C842">>}, + {<<"jhn_stdlib">>, <<"785074F3CA368EAA8E9AF1592BC19AE9EF1F7AF30B2CD6456A6083173A8F5CCB">>}, {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}, - {<<"redbug">>, <<"30477A303BF6A62EF02D7C7AC53A6325104DBB7E791D52FE9E7F8B92488B07EF">>}, {<<"routing_tree">>, <<"72ACEF2095F0EC804F7AFD07EF781DDE5009425A1CA0A28F0706B1DB334A4812">>}, {<<"thoas">>, <<"19A25F31177A17E74004D4840F66D791D4298C5738790FA2CC73731EB911F195">>}]}, {pkg_hash_ext,[ - {<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>}, - {<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>}, + {<<"cowboy">>, <<"179FB65140FB440A17B767AD53B755081506F9596C4DB5C49C0396D8C8643668">>}, + {<<"cowlib">>, <<"58F1E425A9E04176F1D30E20116F57C4E90EF0E187552E9741C465BDF4044F70">>}, {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>}, - {<<"jhn_stdlib">>, <<"7EABD1B01D2DEFF495BF7C5CA1DBA4D3FA0B84DC3AF03CA85F31D52EBB03C6FC">>}, - {<<"nova">>, <<"22F4C3FFDC08DB8568F3DA198647823D63123520F69C7D718A8DB468F4C7A1EA">>}, + {<<"jhn_stdlib">>, <<"2329CD16DEE46704AAB6184D09508E59DBA31C4D3255271DBB7D34D115ECA508">>}, {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}, - {<<"redbug">>, <<"55D6D59697481CA4CC5AD54749AA6D78299AA8A8096027E7AE1F59DB9DC94C78">>}, {<<"routing_tree">>, <<"85982C7AC502892C5179CD2A591331003BACD2D2A71723640BA7D23F45408E6E">>}, {<<"thoas">>, <<"E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A">>}]} ]. diff --git a/src/nova_liveboard.app.src b/src/nova_liveboard.app.src index 6a1f251..34de0b7 100644 --- a/src/nova_liveboard.app.src +++ b/src/nova_liveboard.app.src @@ -1,18 +1,20 @@ {application, nova_liveboard, [ - {description, "Real-time BEAM VM dashboard for Nova"}, + {description, "Real-time BEAM VM dashboard for Nova (Nova + Datastar)"}, {vsn, git}, - {registered, []}, + {registered, [nova_liveboard_tracer]}, {mod, {nova_liveboard_app, []}}, {applications, [ kernel, stdlib, nova, - arizona_core, - arizona_nova + datastar ]}, {env, [ - {nova_apps, [arizona_nova]} + {prefix, "/liveboard"}, + {refresh_ms, 2000}, + {request_buffer, 200} ]}, {modules, []}, - {licenses, ["Apache-2.0"]} + {licenses, ["Apache-2.0"]}, + {links, [{"GitHub", "https://github.com/novaframework/nova_liveboard"}]} ]}. diff --git a/src/nova_liveboard.erl b/src/nova_liveboard.erl index f8dd5fd..8857956 100644 --- a/src/nova_liveboard.erl +++ b/src/nova_liveboard.erl @@ -1,10 +1,30 @@ -module(nova_liveboard). +-moduledoc """ +Configuration helpers for the liveboard. --export([prefix/0]). +All settings live under the `nova_liveboard` application env: +- `prefix` (default `"/liveboard"`) - the path the dashboard mounts on. +- `refresh_ms` (default `2000`) - how often the polled SSE streams repaint. +- `request_buffer` (default `200`) - how many recent requests the tracer keeps. +""". + +-export([prefix/0, refresh_ms/0, request_buffer/0]). + +-doc "The mount path, always as a binary with a leading slash.". -spec prefix() -> binary(). prefix() -> case application:get_env(nova_liveboard, prefix, ~"/liveboard") of - Prefix when is_binary(Prefix) -> Prefix; - Prefix when is_list(Prefix) -> list_to_binary(Prefix) + P when is_binary(P) -> P; + P when is_list(P) -> list_to_binary(P) end. + +-doc "Repaint interval for the polled (non-request) streams, in milliseconds.". +-spec refresh_ms() -> pos_integer(). +refresh_ms() -> + application:get_env(nova_liveboard, refresh_ms, 2000). + +-doc "How many completed requests the tracer ring buffer retains.". +-spec request_buffer() -> pos_integer(). +request_buffer() -> + application:get_env(nova_liveboard, request_buffer, 200). diff --git a/src/nova_liveboard_action_controller.erl b/src/nova_liveboard_action_controller.erl new file mode 100644 index 0000000..2c82366 --- /dev/null +++ b/src/nova_liveboard_action_controller.erl @@ -0,0 +1,43 @@ +-module(nova_liveboard_action_controller). +-moduledoc """ +POST handlers for the Requests page controls (arm / disarm / clear deep trace). + +Each mutates `nova_liveboard_tracer` and returns a one-shot Datastar response: +a normal `{status, 200, ...}` reply whose body is SSE-framed patch events. +Nova's built-in `handle_status` sends it, and Datastar applies the patches to +the clicking client immediately. Other open clients converge via the tracer's +own notifications to their Requests stream. +""". + +-export([trace_start/1, trace_stop/1, clear/1]). + +-define(ARM_COUNT, 10). + +trace_start(_Req) -> + nova_liveboard_tracer:start_trace(?ARM_COUNT), + oneshot([toolbar_patch()]). + +trace_stop(_Req) -> + nova_liveboard_tracer:stop_trace(), + oneshot([toolbar_patch()]). + +clear(_Req) -> + nova_liveboard_tracer:clear(), + oneshot([toolbar_patch(), feed_cleared_patch()]). + +%% --------------------------------------------------------------------------- + +oneshot(Frames) -> + {status, 200, maps:from_list(datastar:sse_headers()), iolist_to_binary(Frames)}. + +toolbar_patch() -> + datastar:patch_elements( + nova_liveboard_html:requests_toolbar_html(nova_liveboard_tracer:trace_state()), + #{selector => ~"#req-toolbar", mode => inner} + ). + +feed_cleared_patch() -> + datastar:patch_elements( + ~"

cleared - waiting for the next request

", + #{selector => ~"#req-feed", mode => inner} + ). diff --git a/src/nova_liveboard_app.erl b/src/nova_liveboard_app.erl index 4ebccd1..409375e 100644 --- a/src/nova_liveboard_app.erl +++ b/src/nova_liveboard_app.erl @@ -1,10 +1,21 @@ -module(nova_liveboard_app). +-moduledoc """ +Application entry point. + +Starts the supervision tree (the request tracer) and registers the +`{stream, ...}` Datastar SSE return-handler with Nova. The request-tracing +plugin is *not* auto-registered: it is a global Nova plugin, so the host wires +it into its own `{nova, [{plugins, ...}]}` config (see the README). The +dashboard's pages work either way; only the Requests feed needs the plugin. +""". +-behaviour(application). -export([start/2, stop/1]). start(_StartType, _StartArgs) -> - arizona_nova:register_views(nova_liveboard, fun nova_liveboard_controller:resolve_view/1), - {ok, self()}. + {ok, Sup} = nova_liveboard_sup:start_link(), + ok = nova_liveboard_sse:register(), + {ok, Sup}. stop(_State) -> ok. diff --git a/src/nova_liveboard_apps_view.erl b/src/nova_liveboard_apps_view.erl deleted file mode 100644 index 988594e..0000000 --- a/src/nova_liveboard_apps_view.erl +++ /dev/null @@ -1,64 +0,0 @@ --module(nova_liveboard_apps_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(5000, self(), refresh); - false -> ok - end, - Apps = nova_liveboard_data:running_applications(), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"apps_view", - applications => Apps - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"applications", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Apps = arizona_template:get_binding(applications, Bindings), - arizona_template:from_html( - ~"""" -
-

{integer_to_binary(length(Apps))} running applications

-
- - - - - - - - - - {arizona_template:render_list(fun(App) -> - arizona_template:from_html(~""" - - - - - - """) - end, Apps)} - -
ApplicationVersionDescription
{maps:get(name, App)}{maps:get(version, App)}{maps:get(description, App)}
-
-
- """" - ). - -handle_info(refresh, View) -> - erlang:send_after(5000, self(), refresh), - Apps = nova_liveboard_data:running_applications(), - State = arizona_view:get_state(View), - UpdatedState = arizona_stateful:put_binding(applications, Apps, State), - {[], arizona_view:update_state(UpdatedState, View)}. diff --git a/src/nova_liveboard_controller.erl b/src/nova_liveboard_controller.erl deleted file mode 100644 index 6ff12e2..0000000 --- a/src/nova_liveboard_controller.erl +++ /dev/null @@ -1,58 +0,0 @@ --module(nova_liveboard_controller). - --export([index/1, resolve_view/1]). - --spec index(Req :: map()) -> {status, integer(), map(), iodata()}. -index(CowboyReq) -> - Path = cowboy_req:path(CowboyReq), - {ViewModule, MountArg} = resolve_view_module(Path), - ArizonaReq = arizona_cowboy_request:new(CowboyReq), - try - View = arizona_view:call_mount_callback(ViewModule, MountArg, ArizonaReq), - {Html, _RenderView} = arizona_renderer:render_layout(View), - {status, 200, #{<<"content-type">> => <<"text/html; charset=utf-8">>}, Html} - catch - Error:Reason:Stacktrace -> - logger:error(~"Liveboard render error: ~p:~p~n~p", [Error, Reason, Stacktrace]), - {status, 500, #{<<"content-type">> => <<"text/html">>}, <<"Internal Server Error">>} - end. - --spec resolve_view(map()) -> {view, module(), term(), list()}. -resolve_view(#{path := Path}) -> - {ViewModule, MountArg} = resolve_view_module(Path), - {view, ViewModule, MountArg, []}. - -resolve_view_module(Path) -> - case page_from_path(Path) of - <<"processes">> -> {nova_liveboard_processes_view, undefined}; - <<"ets">> -> {nova_liveboard_ets_view, undefined}; - <<"applications">> -> {nova_liveboard_apps_view, undefined}; - <<"ports">> -> {nova_liveboard_ports_view, undefined}; - <<"supervisors">> -> {nova_liveboard_sup_view, undefined}; - <<"metrics">> -> {nova_liveboard_metrics_view, undefined}; - <<"database">> -> {nova_liveboard_database_view, undefined}; - <<"schemas">> -> {nova_liveboard_schemas_view, undefined}; - _ -> {nova_liveboard_system_view, undefined} - end. - -page_from_path(Path) -> - Pages = [ - <<"processes">>, - <<"ets">>, - <<"applications">>, - <<"ports">>, - <<"supervisors">>, - <<"metrics">>, - <<"database">>, - <<"schemas">> - ], - case binary:split(Path, <<"/">>, [global, trim_all]) of - [] -> - <<"system">>; - Parts -> - Last = lists:last(Parts), - case lists:member(Last, Pages) of - true -> Last; - false -> <<"system">> - end - end. diff --git a/src/nova_liveboard_data.erl b/src/nova_liveboard_data.erl index e283fa9..689a96f 100644 --- a/src/nova_liveboard_data.erl +++ b/src/nova_liveboard_data.erl @@ -22,6 +22,22 @@ kura_schemas/1 ]). +%% These functions either call Kura's optional, runtime-detected API +%% (kura_repo/kura_schema/kura_migrator are not dependencies) or work with ETS +%% opaque tids and the undocumented scheduler_wall_time_all tuple; both are +%% unresolvable for dialyzer by design, so the warnings are suppressed at source. +-dialyzer( + {nowarn_function, [ + scheduler_info/0, + kura_repo_info/1, + kura_migration_status/1, + schema_info/1, + format_ets_id/1, + scheduler_wall_time/0, + pool_stats_from_pid/2 + ]} +). + -spec system_info() -> map(). system_info() -> {Total, Allocated, _Worst} = memsup_or_vm_memory(), @@ -35,6 +51,7 @@ system_info() -> port_limit => erlang:system_info(port_limit), atom_count => erlang:system_info(atom_count), atom_limit => erlang:system_info(atom_limit), + run_queue => erlang:statistics(run_queue), ets_count => length(ets:all()), scheduler_count => erlang:system_info(schedulers), scheduler_online => erlang:system_info(schedulers_online), diff --git a/src/nova_liveboard_database_view.erl b/src/nova_liveboard_database_view.erl deleted file mode 100644 index 967b625..0000000 --- a/src/nova_liveboard_database_view.erl +++ /dev/null @@ -1,145 +0,0 @@ --module(nova_liveboard_database_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(5000, self(), refresh); - false -> ok - end, - Repos = nova_liveboard_data:kura_repos(), - RepoData = [build_repo_data(R) || R <- Repos], - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"database_view", - repos => RepoData - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"database", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Repos = arizona_template:get_binding(repos, Bindings), - arizona_template:from_html( - ~"""" -
-

Auto-refreshes every 5s

- {arizona_template:render_list(fun(Repo) -> - arizona_template:from_html(~""" -
-
{maps:get(module, Repo)}
- -
-
-
Database
-
{maps:get(database, Repo)}
-
{maps:get(host_display, Repo)}
-
-
-
Pool Status
-
{maps:get(pool_status, Repo)}
-
size: {maps:get(pool_size_display, Repo)}
-
-
-
Available
-
{maps:get(available_display, Repo)}
-
-
-
-
-
-
Checked Out
-
{maps:get(checked_out_display, Repo)}
-
-
-
-
-
- -
Migrations ({maps:get(applied_display, Repo)} applied, {maps:get(pending_display, Repo)} pending)
- {maps:get(migration_html, Repo)} -
- """) - end, Repos)} -
- """" - ). - -handle_info(refresh, View) -> - erlang:send_after(5000, self(), refresh), - Repos = nova_liveboard_data:kura_repos(), - RepoData = [build_repo_data(R) || R <- Repos], - State = arizona_view:get_state(View), - UpdatedState = arizona_stateful:put_binding(repos, RepoData, State), - {[], arizona_view:update_state(UpdatedState, View)}. - -%% Internal - -build_repo_data(RepoMod) -> - Info = nova_liveboard_data:kura_repo_info(RepoMod), - Pool = maps:get(pool, Info), - PoolStats = nova_liveboard_data:kura_pool_stats(Pool), - Migrations = nova_liveboard_data:kura_migration_status(RepoMod), - PoolSize = maps:get(pool_size, Info), - Available = maps:get(available, PoolStats), - CheckedOut = maps:get(checked_out, PoolStats), - PendingCount = length([M || M <- Migrations, maps:get(status, M) =:= ~"pending"]), - AppliedCount = length([M || M <- Migrations, maps:get(status, M) =:= ~"up"]), - #{ - module => maps:get(module, Info), - database => maps:get(database, Info), - host_display => iolist_to_binary([ - maps:get(hostname, Info), ~":", integer_to_binary(maps:get(port, Info)) - ]), - pool_status => maps:get(status, PoolStats), - status_class => pool_status_class(maps:get(status, PoolStats)), - pool_size_display => integer_to_binary(PoolSize), - available_display => integer_to_binary(Available), - available_pct => pool_pct(Available, PoolSize), - checked_out_display => integer_to_binary(CheckedOut), - checked_out_pct => pool_pct(CheckedOut, PoolSize), - applied_display => integer_to_binary(AppliedCount), - pending_display => integer_to_binary(PendingCount), - migration_html => migration_table_html(Migrations) - }. - -migration_table_html([]) -> - ~"

No migrations found

"; -migration_table_html(Migrations) -> - Header = - <<"", "", - "">>, - Rows = [migration_row(M) || M <- Migrations], - Footer = ~"
VersionModuleStatus
", - iolist_to_binary([Header, Rows, Footer]). - -migration_row(Mig) -> - Badge = - case maps:get(status, Mig) of - ~"up" -> ~"up"; - ~"pending" -> ~"pending"; - S -> S - end, - iolist_to_binary([ - ~"", - maps:get(version, Mig), - ~"", - maps:get(module, Mig), - ~"", - Badge, - ~"" - ]). - -pool_status_class(~"ready") -> ~"text-green"; -pool_status_class(~"busy") -> ~"text-amber"; -pool_status_class(_) -> ~"text-dim". - -pool_pct(_, 0) -> ~"0"; -pool_pct(Val, Total) -> integer_to_binary(min(100, (Val * 100) div Total)). diff --git a/src/nova_liveboard_ets_view.erl b/src/nova_liveboard_ets_view.erl deleted file mode 100644 index 9907b8d..0000000 --- a/src/nova_liveboard_ets_view.erl +++ /dev/null @@ -1,76 +0,0 @@ --module(nova_liveboard_ets_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(3000, self(), refresh); - false -> ok - end, - Tables = nova_liveboard_data:ets_tables(), - Sorted = lists:sort( - fun(A, B) -> maps:get(memory_bytes, A) >= maps:get(memory_bytes, B) end, Tables - ), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"ets_view", - tables => Sorted - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"ets", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Tables = arizona_template:get_binding(tables, Bindings), - arizona_template:from_html( - ~"""" -
-

{integer_to_binary(length(Tables))} tables · Auto-refreshes every 3s

-
- - - - - - - - - - - - - {arizona_template:render_list(fun(Table) -> - arizona_template:from_html(~""" - - - - - - - - - """) - end, Tables)} - -
NameTypeProtectionSizeMemoryOwner
{maps:get(name, Table)}{maps:get(type, Table)}{maps:get(protection, Table)}{nova_liveboard_data:format_number(maps:get(size, Table))}{nova_liveboard_data:format_bytes(maps:get(memory_bytes, Table))}{maps:get(owner, Table)}
-
-
- """" - ). - -handle_info(refresh, View) -> - erlang:send_after(3000, self(), refresh), - Tables = nova_liveboard_data:ets_tables(), - Sorted = lists:sort( - fun(A, B) -> maps:get(memory_bytes, A) >= maps:get(memory_bytes, B) end, Tables - ), - State = arizona_view:get_state(View), - UpdatedState = arizona_stateful:put_binding(tables, Sorted, State), - {[], arizona_view:update_state(UpdatedState, View)}. diff --git a/src/nova_liveboard_html.erl b/src/nova_liveboard_html.erl new file mode 100644 index 0000000..5e85de7 --- /dev/null +++ b/src/nova_liveboard_html.erl @@ -0,0 +1,876 @@ +-module(nova_liveboard_html). +-moduledoc """ +Pure HTML rendering for the liveboard - "BEAM mission control". + +Every function here takes already-gathered data (from `nova_liveboard_data` +and `nova_liveboard_tracer`) and returns `iodata()`. No process state, no +side effects beyond reading the static `nova_liveboard:prefix/0` config, so +the whole module is unit-testable in isolation. + +`page/4` renders the full document shell (vitals deck + instrument nav + +main stage). The per-region functions (`vitals_html/1`, `overview_html/1`, +`processes_html/2`, ...) render the *inner* of a live region; the page +controller uses them for the first paint and `nova_liveboard_sse` re-emits +them as Datastar patches as the VM changes. +""". + +-export([ + page/4, + vitals_html/1, + overview_html/1, + metrics_html/1, + processes_html/2, + ets_html/1, + apps_html/1, + ports_html/1, + sup_tree_html/3, + requests_html/3, + requests_toolbar_html/1, + request_row/1, + request_detail_html/1, + database_html/1, + schemas_html/1, + csp_headers/0, + html_escape/1 +]). + +-define(NAV, [ + {overview, ~"Overview", ~"\x{25C8}"}, + {processes, ~"Processes", ~"\x{2261}"}, + {metrics, ~"Metrics", ~"\x{2248}"}, + {requests, ~"Requests", ~"\x{21AF}"}, + {supervisors, ~"Supervisors", ~"\x{2638}"}, + {applications, ~"Applications", ~"\x{25A4}"}, + {ets, ~"ETS", ~"\x{229E}"}, + {ports, ~"Ports", ~"\x{21C4}"} +]). + +%% --------------------------------------------------------------------------- +%% document shell +%% --------------------------------------------------------------------------- + +-doc """ +Full HTML document: head + vitals deck + nav + the main stage. + +`StreamPath` is the SSE path the main region should subscribe to (so it +repaints live), or `none` for a static page (e.g. a request detail) that must +not be overwritten by a stream. +""". +-spec page(atom(), binary() | none, iodata(), iodata()) -> iodata(). +page(Active, StreamPath, VitalsInner, MainInner) -> + P = nova_liveboard:prefix(), + [ + ~"", + ~"", + ~"nova liveboard \x{00B7} ", + page_title(Active), + ~"", + ~"", + ~"
", + header_html(P, VitalsInner), + ~"
", + nav_html(P, Active), + ~"
", + stage_inner(StreamPath, MainInner), + ~"
" + ]. + +stage_inner(none, MainInner) -> + [~"
", MainInner, ~"
"]; +stage_inner(StreamPath, MainInner) -> + [ + ~"
", + MainInner, + ~"
" + ]. + +header_html(P, VitalsInner) -> + [ + ~"
\x{2726}", + ~"novaliveboard", + ~"live
", + %% data-init opens the always-on vitals stream; #vitals is patched inner. + ~"
", + VitalsInner, + ~"
" + ]. + +nav_html(P, Active) -> + Items = [nav_item(P, Active, Page, Label, Glyph) || {Page, Label, Glyph} <- ?NAV], + Kura = + case nova_liveboard_data:kura_available() of + true -> + [ + ~"
data
", + nav_item(P, Active, database, ~"Database", ~"\x{2317}"), + nav_item(P, Active, schemas, ~"Schemas", ~"\x{229F}") + ]; + false -> + [] + end, + [~""]. + +nav_item(P, Active, Page, Label, Glyph) -> + Cls = + case Page =:= Active of + true -> ~"item active"; + false -> ~"item" + end, + [ + ~"", + Glyph, + ~"", + Label, + ~"" + ]. + +nav_path(overview) -> ~""; +nav_path(Page) -> atom_to_binary(Page). + +page_title(overview) -> ~"overview"; +page_title(Page) -> atom_to_binary(Page). + +%% --------------------------------------------------------------------------- +%% vitals deck (always-on header strip) +%% --------------------------------------------------------------------------- + +-doc "The live header gauges: capacity vs limits + memory + uptime.". +-spec vitals_html(map()) -> iodata(). +vitals_html(Sys) -> + #{ + process_count := Procs, + process_limit := ProcLimit, + port_count := Ports, + port_limit := PortLimit, + atom_count := Atoms, + atom_limit := AtomLimit, + run_queue := RunQ, + uptime := Uptime, + memory := Mem + } = Sys, + Total = maps:get(total, Mem), + [ + vital_gauge(~"processes", Procs, ProcLimit), + vital_gauge(~"ports", Ports, PortLimit), + vital_gauge(~"atoms", Atoms, AtomLimit), + vital_stat(~"run queue", integer_to_binary(RunQ)), + vital_stat(~"memory", nova_liveboard_data:format_bytes(Total)), + vital_stat(~"uptime", nova_liveboard_data:format_uptime(Uptime)) + ]. + +vital_gauge(Label, Used, Limit) -> + Pct = pct(Used, Limit), + [ + ~"
", + Label, + ~"", + nova_liveboard_data:format_number(Used), + ~"/", + nova_liveboard_data:format_number(Limit), + ~"
", + bar(Pct, util_class(Pct)), + ~"
" + ]. + +vital_stat(Label, Value) -> + [ + ~"
", + Label, + ~"
", + Value, + ~"
" + ]. + +%% --------------------------------------------------------------------------- +%% overview +%% --------------------------------------------------------------------------- + +-doc "System identity + memory breakdown + capacity panels.". +-spec overview_html(map()) -> iodata(). +overview_html(Sys) -> + #{ + otp_release := Otp, + erts_version := Erts, + system_architecture := Arch, + scheduler_count := Scheds, + scheduler_online := SchedsOn, + ets_count := EtsCount, + memory := Mem + } = Sys, + [ + ~"
", + panel( + ~"node", + [ + kv(~"otp release", Otp), + kv(~"erts", Erts), + kv(~"architecture", Arch), + kv(~"schedulers", [ + integer_to_binary(SchedsOn), ~" / ", integer_to_binary(Scheds) + ]), + kv(~"ets tables", integer_to_binary(EtsCount)) + ] + ), + panel(~"memory", memory_bars(Mem)), + ~"
" + ]. + +memory_bars(Mem) -> + Total = maps:get(total, Mem), + Rows = [ + {~"processes", maps:get(processes, Mem)}, + {~"binary", maps:get(binary, Mem)}, + {~"ets", maps:get(ets, Mem)}, + {~"code", maps:get(code, Mem)}, + {~"atom", maps:get(atom, Mem)}, + {~"system", maps:get(system, Mem)} + ], + [ + [ + ~"
", + Label, + ~"", + nova_liveboard_data:format_bytes(Bytes), + ~"
", + bar(pct(Bytes, Total), ~"cool"), + ~"
" + ] + || {Label, Bytes} <- Rows + ]. + +%% --------------------------------------------------------------------------- +%% metrics (sparklines + scheduler bars) +%% --------------------------------------------------------------------------- + +-doc "Live sparklines for memory/IO/run-queue + scheduler utilisation bars.". +-spec metrics_html(map()) -> iodata(). +metrics_html(M) -> + Spark = fun(Key, Label, Fmt) -> + Vals = queue:to_list(maps:get(Key, M)), + spark_card(Label, Fmt(last_val(Vals)), Vals) + end, + Bytes = fun nova_liveboard_data:format_bytes/1, + Int = fun integer_to_binary/1, + [ + ~"
", + Spark(total_memory, ~"total memory", Bytes), + Spark(process_memory, ~"process memory", Bytes), + Spark(binary_memory, ~"binary memory", Bytes), + Spark(process_count, ~"processes", Int), + Spark(run_queue, ~"run queue", Int), + Spark(io_input, ~"io in / tick", Bytes), + Spark(io_output, ~"io out / tick", Bytes), + ~"
", + panel(~"scheduler utilisation", sched_bars(maps:get(scheduler_util, M, []))) + ]. + +spark_card(Label, Value, Vals) -> + Pts = nova_liveboard_data:sparkline_points(Vals, 240, 48), + [ + ~"
", + Label, + ~"
", + Value, + ~"
", + ~"
" + ]. + +sched_bars([]) -> + empty(~"collecting scheduler samples..."); +sched_bars(Utils) -> + [ + begin + Pct = min(100, round(maps:get(util, U))), + PctB = integer_to_binary(Pct), + [ + ~"
S", + integer_to_binary(maps:get(id, U)), + ~"", + bar(Pct, util_class(Pct)), + ~"", + PctB, + ~"%
" + ] + end + || U <- Utils + ]. + +%% --------------------------------------------------------------------------- +%% processes +%% --------------------------------------------------------------------------- + +-doc "Top processes table, sorted by `SortBy`, with sort controls.". +-spec processes_html([map()], atom()) -> iodata(). +processes_html(Procs, SortBy) -> + [ + ~"
sort by", + sort_btn(SortBy, memory, ~"memory"), + sort_btn(SortBy, reductions, ~"reductions"), + sort_btn(SortBy, message_queue_len, ~"msg queue"), + ~"
", + table( + [~"pid", ~"name", ~"memory", ~"reductions", ~"msgq", ~"current"], + [ + [ + cell_mono(maps:get(pid, Pr)), + cell(maps:get(name, Pr)), + cell_num(nova_liveboard_data:format_bytes(maps:get(memory, Pr))), + cell_num(nova_liveboard_data:format_number(maps:get(reductions, Pr))), + cell_num(integer_to_binary(maps:get(message_queue_len, Pr))), + cell_mono(maps:get(current_function, Pr)) + ] + || Pr <- Procs + ] + ) + ]. + +sort_btn(Active, Key, Label) -> + P = nova_liveboard:prefix(), + Cls = + case Active =:= Key of + true -> ~"chip active"; + false -> ~"chip" + end, + %% A plain link so the single page stream reopens with the new sort, rather + %% than stacking a second EventSource over the running one. + [ + ~"", + Label, + ~"" + ]. + +%% --------------------------------------------------------------------------- +%% ets / applications / ports +%% --------------------------------------------------------------------------- + +-doc "ETS tables table.". +-spec ets_html([map()]) -> iodata(). +ets_html(Tables) -> + table( + [~"name", ~"id", ~"type", ~"protection", ~"size", ~"memory", ~"owner"], + [ + [ + cell(maps:get(name, T)), + cell_mono(maps:get(id, T)), + cell(maps:get(type, T)), + cell(maps:get(protection, T)), + cell_num(nova_liveboard_data:format_number(maps:get(size, T))), + cell_num(nova_liveboard_data:format_bytes(maps:get(memory_bytes, T))), + cell_mono(maps:get(owner, T)) + ] + || T <- Tables + ] + ). + +-doc "Running applications table.". +-spec apps_html([map()]) -> iodata(). +apps_html(Apps) -> + table( + [~"application", ~"version", ~"description"], + [ + [ + cell_strong(maps:get(name, A)), + cell_mono(maps:get(version, A)), + cell(maps:get(description, A)) + ] + || A <- Apps + ] + ). + +-doc "Open ports table.". +-spec ports_html([map()]) -> iodata(). +ports_html(Ports) -> + table( + [~"id", ~"name", ~"connected", ~"input", ~"output"], + [ + [ + cell_mono(maps:get(id, Po)), + cell(maps:get(name, Po)), + cell_mono(maps:get(connected, Po)), + cell_num(nova_liveboard_data:format_bytes(maps:get(input, Po))), + cell_num(nova_liveboard_data:format_bytes(maps:get(output, Po))) + ] + || Po <- Ports + ] + ). + +%% --------------------------------------------------------------------------- +%% supervision tree +%% --------------------------------------------------------------------------- + +-doc "The supervision tree for `Active` (static process structure) + app chooser.". +-spec sup_tree_html([atom()], atom(), {ok, list()} | {error, term()}) -> iodata(). +sup_tree_html(Apps, Active, Result) -> + [ + ~"
application", + [app_chip(A, Active) || A <- lists:sort(Apps)], + ~"
", + sup_body(Active, Result) + ]. + +app_chip(App, Active) -> + P = nova_liveboard:prefix(), + Name = atom_to_binary(App), + Cls = + case App =:= Active of + true -> ~"chip active"; + false -> ~"chip" + end, + [ + ~"", + html_escape(Name), + ~"" + ]. + +sup_body(_Active, {ok, Tree}) -> + panel(~"supervision tree", [~"
", tree_nodes(Tree, 0), ~"
"]); +sup_body(Active, {error, Reason}) -> + empty([ + ~"no supervisor found for ", + html_escape(atom_to_binary(Active)), + ~" (", + html_escape(to_bin(Reason)), + ~")" + ]). + +tree_nodes(Nodes, Depth) -> + [tree_node(N, Depth) || N <- Nodes]. + +tree_node(Node, Depth) -> + #{type := Type, name := Name, pid := Pid, status := Status} = Node, + Children = maps:get(children, Node, []), + Mem = maps:get(memory, Node, 0), + [ + ~"
", + kind_glyph(Type), + ~"", + html_escape(Name), + ~"", + html_escape(Pid), + ~"", + nova_liveboard_data:format_bytes(Mem), + ~"
", + tree_nodes(Children, Depth + 1) + ]. + +kind_glyph(supervisor) -> ~"\x{2638}"; +kind_glyph(_) -> ~"\x{25CF}". + +kind_class(supervisor) -> ~"sup"; +kind_class(_) -> ~"worker". + +%% --------------------------------------------------------------------------- +%% requests (the live request tracer feed) +%% --------------------------------------------------------------------------- + +-doc """ +The live request feed. `Enabled` reflects whether the tracing plugin is +registered; when it is not, a setup hint is shown instead of an empty feed. +`Trace` is the deep-trace arming state from `nova_liveboard_tracer`. +""". +-spec requests_html([map()], map(), boolean()) -> iodata(). +requests_html(_Requests, _Trace, false) -> + setup_hint(); +requests_html(Requests, Trace, true) -> + [ + ~"
", + requests_toolbar_html(Trace), + ~"
", + case Requests of + [] -> empty(~"no requests captured yet - hit any route on this node"); + _ -> [request_row(R) || R <- Requests] + end, + ~"
" + ]. + +-doc "The arm/disarm/clear toolbar (patched on its own as trace state changes).". +-spec requests_toolbar_html(map()) -> iodata(). +requests_toolbar_html(Trace) -> + P = nova_liveboard:prefix(), + Armed = maps:get(armed, Trace, false), + Remaining = maps:get(remaining, Trace, 0), + {StateCls, StateText} = + case Armed of + true -> + {~"chip active", [ + ~"deep trace armed \x{00B7} ", integer_to_binary(Remaining), ~" left" + ]}; + false -> + {~"chip", ~"deep trace off"} + end, + [ + ~"", + StateText, + ~"", + ~"", + ~"", + ~"" + ]. + +-doc "A single request row (also emitted as a prepended SSE patch on arrival).". +-spec request_row(map()) -> iodata(). +request_row(R) -> + #{ + id := Id, + method := Method, + path := Path, + status := Status, + duration_us := Dur, + reductions := Reds, + handler := Handler, + spawned := Spawned + } = R, + P = nova_liveboard:prefix(), + [ + ~"", + html_escape(Method), + ~"", + html_escape(Path), + ~"", + status_bin(Status), + ~"", + fmt_us(Dur), + ~"", + nova_liveboard_data:format_number(Reds), + ~" reds", + spawn_badge(Spawned), + ~"", + html_escape(Handler), + ~"" + ]. + +spawn_badge(N) when is_integer(N), N > 0 -> + [~"\x{2387} ", integer_to_binary(N)]; +spawn_badge(_) -> + ~"\x{00B7}". + +-doc "Detail page for one captured request: timings + spawned-process tree.". +-spec request_detail_html(map() | undefined) -> iodata(). +request_detail_html(undefined) -> + empty(~"request not found (the buffer may have rolled over)"); +request_detail_html(R) -> + #{ + method := Method, + path := Path, + status := Status, + duration_us := Dur, + reductions := Reds, + mem := Mem, + handler := Handler, + spawned_tree := Tree + } = R, + [ + ~"
", + html_escape(Method), + ~"", + html_escape(Path), + ~"
", + ~"
", + panel(~"timings", [ + kv(~"status", status_bin(Status)), + kv(~"duration", fmt_us(Dur)), + kv(~"reductions", nova_liveboard_data:format_number(Reds)), + kv(~"peak memory", nova_liveboard_data:format_bytes(Mem)), + kv(~"handler", html_escape(Handler)) + ]), + panel( + [~"spawned processes (", integer_to_binary(length(Tree)), ~")"], + spawned_tree_html(Tree) + ), + ~"
" + ]. + +spawned_tree_html([]) -> + empty(~"no processes spawned during this request (or deep trace was off)"); +spawned_tree_html(Tree) -> + [ + ~"
", + [ + [ + ~"
\x{25CF}", + html_escape(maps:get(mfa, S)), + ~"", + html_escape(maps:get(pid, S)), + ~"
" + ] + || S <- Tree + ], + ~"
" + ]. + +setup_hint() -> + [ + ~"

request tracing is off

", + ~"

Add the liveboard tracing plugin to your Nova config to capture every ", + ~"request handled by this node, then watch them stream in here live.

", + ~"
{nova, [\n",
+        ~"  {plugins, [\n",
+        ~"    {pre_request,  nova_liveboard_trace_plugin, #{}},\n",
+        ~"    {post_request, nova_liveboard_trace_plugin, #{}}\n",
+        ~"  ]}\n",
+        ~"]}.
" + ]. + +%% --------------------------------------------------------------------------- +%% kura: database + schemas +%% --------------------------------------------------------------------------- + +-doc "Kura repo pool panels.". +-spec database_html([map()]) -> iodata(). +database_html([]) -> + empty(~"no kura repos detected"); +database_html(Repos) -> + [ + ~"
", + [ + panel(html_escape(maps:get(module, Repo)), [ + kv(~"database", html_escape(maps:get(database, Repo))), + kv(~"host", [ + html_escape(maps:get(hostname, Repo)), ~":", to_bin(maps:get(port, Repo)) + ]), + kv(~"pool size", integer_to_binary(maps:get(pool_size, Repo))), + pool_stat(maps:get(pool, Repo)) + ]) + || Repo <- Repos + ], + ~"
" + ]. + +pool_stat(Stats) -> + Status = maps:get(status, Stats, ~"unknown"), + [ + ~"
pool", + html_escape(Status), + ~" avail ", + integer_to_binary(maps:get(available, Stats, 0)), + ~" \x{00B7} busy ", + integer_to_binary(maps:get(checked_out, Stats, 0)), + ~"
" + ]. + +-doc "Kura schema definitions (fields, associations, indexes).". +-spec schemas_html([map()]) -> iodata(). +schemas_html([]) -> + empty(~"no kura schemas detected"); +schemas_html(Schemas) -> + [ + panel([html_escape(maps:get(module, S)), ~" \x{2192} ", html_escape(maps:get(table, S))], [ + table( + [~"field", ~"type", ~"flags"], + [ + [ + cell_strong(maps:get(name, F)), + cell_mono(maps:get(type, F)), + cell(field_flags(F)) + ] + || F <- maps:get(fields, S) + ] + ) + ]) + || S <- Schemas + ]. + +field_flags(F) -> + PK = + case maps:get(primary_key, F, false) of + true -> [~"pk "]; + false -> [] + end, + V = + case maps:get(virtual, F, false) of + true -> [~"virtual"]; + false -> [] + end, + case [PK, V] of + [[], []] -> ~"\x{00B7}"; + Flags -> Flags + end. + +%% --------------------------------------------------------------------------- +%% shared widgets +%% --------------------------------------------------------------------------- + +panel(Title, Body) -> + [ + ~"

", + Title, + ~"

", + Body, + ~"
" + ]. + +bar(Pct, Cls) -> + PctB = integer_to_binary(min(100, max(0, Pct))), + [ + ~"
" + ]. + +table(Headers, Rows) -> + [ + ~"
", + [[~""] || H <- Headers], + ~"", + case Rows of + [] -> + [ + ~"" + ]; + _ -> + [[~"", Cells, ~""] || Cells <- Rows] + end, + ~"
", H, ~"
nothing here
" + ]. + +cell(V) -> [~"", html_escape(V), ~""]. +cell_strong(V) -> [~"", html_escape(V), ~""]. +cell_mono(V) -> [~"", html_escape(V), ~""]. +cell_num(V) -> [~"", html_escape(V), ~""]. + +kv(K, V) -> + [~"
", K, ~"", V, ~"
"]. + +empty(Msg) -> + [~"

", html_escape(Msg), ~"

"]. + +%% --------------------------------------------------------------------------- +%% classification helpers +%% --------------------------------------------------------------------------- + +util_class(P) when P >= 80 -> ~"hot"; +util_class(P) when P >= 50 -> ~"warm"; +util_class(_) -> ~"cool". + +status_class(~"running") -> ~"ok"; +status_class(~"waiting") -> ~"ok"; +status_class(~"runnable") -> ~"warm"; +status_class(~"restarting") -> ~"bad"; +status_class(~"dead") -> ~"bad"; +status_class(_) -> ~"idle". + +status_code_class(S) when S >= 500 -> ~"s5"; +status_code_class(S) when S >= 400 -> ~"s4"; +status_code_class(S) when S >= 300 -> ~"s3"; +status_code_class(_) -> ~"s2". + +method_class(~"GET") -> ~"get"; +method_class(~"POST") -> ~"post"; +method_class(~"PUT") -> ~"put"; +method_class(~"DELETE") -> ~"delete"; +method_class(_) -> ~"other". + +pool_class(~"ready") -> ~"s2"; +pool_class(~"busy") -> ~"s4"; +pool_class(~"down") -> ~"s5"; +pool_class(_) -> ~"idle". + +status_bin(undefined) -> ~"-"; +status_bin(S) when is_integer(S) -> integer_to_binary(S); +status_bin(S) -> to_bin(S). + +fmt_us(Us) when Us >= 1000000 -> + iolist_to_binary(io_lib:format("~.2f s", [Us / 1000000])); +fmt_us(Us) when Us >= 1000 -> + iolist_to_binary(io_lib:format("~.1f ms", [Us / 1000])); +fmt_us(Us) -> + [integer_to_binary(Us), ~" \x{00B5}s"]. + +pct(_Used, 0) -> 0; +pct(Used, Limit) -> round(Used / Limit * 100). + +last_val([]) -> 0; +last_val(L) -> lists:last(L). + +to_bin(B) when is_binary(B) -> B; +to_bin(A) when is_atom(A) -> atom_to_binary(A); +to_bin(I) when is_integer(I) -> integer_to_binary(I); +to_bin(T) -> iolist_to_binary(io_lib:format("~p", [T])). + +%% --------------------------------------------------------------------------- +%% security + escaping +%% --------------------------------------------------------------------------- + +-doc """ +The dashboard's response headers. Strict CSP: everything is same-origin +(self-hosted datastar.js, fonts, css), so any stray off-origin request fails +loudly. `unsafe-eval` is required only for Datastar's `data-*` expression +evaluation; the privacy-critical directives stay `'self'`. +""". +-spec csp_headers() -> map(). +csp_headers() -> + #{ + ~"content-type" => ~"text/html; charset=utf-8", + ~"content-security-policy" => + ~"default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'" + }. + +-doc "Escape `& < > \"` for safe interpolation into HTML.". +-spec html_escape(iodata() | atom() | integer()) -> binary(). +html_escape(B) when is_binary(B) -> + B1 = binary:replace(B, ~"&", ~"&", [global]), + B2 = binary:replace(B1, ~"<", ~"<", [global]), + B3 = binary:replace(B2, ~">", ~">", [global]), + binary:replace(B3, ~"\"", ~""", [global]); +html_escape(V) -> + html_escape(to_bin(V)). diff --git a/src/nova_liveboard_layout.erl b/src/nova_liveboard_layout.erl deleted file mode 100644 index c125677..0000000 --- a/src/nova_liveboard_layout.erl +++ /dev/null @@ -1,65 +0,0 @@ --module(nova_liveboard_layout). --compile({parse_transform, arizona_parse_transform}). - --export([render/1]). - --export([kura_nav/2]). - -render(Bindings) -> - arizona_template:from_html( - ~""" - - - - - - Nova Liveboard - - - - - -
- {arizona_template:render_slot(arizona_template:get_binding(main_content, Bindings))} -
- - - """ - ). - -nav_class(Active, Page) when Active =:= Page -> ~"active"; -nav_class(_, _) -> ~"". - -kura_nav(Prefix, Active) -> - case nova_liveboard_data:kura_available() of - true -> - arizona_template:from_html( - ~""" - - >)}">Database - >)}">Schemas - """ - ); - false -> - arizona_template:from_html( - ~""" - - """ - ) - end. diff --git a/src/nova_liveboard_metrics_view.erl b/src/nova_liveboard_metrics_view.erl deleted file mode 100644 index 6fdad96..0000000 --- a/src/nova_liveboard_metrics_view.erl +++ /dev/null @@ -1,163 +0,0 @@ --module(nova_liveboard_metrics_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(2000, self(), refresh); - false -> ok - end, - Metrics = nova_liveboard_data:collect_metrics(undefined), - Prefix = nova_liveboard:prefix(), - Bindings = metrics_to_bindings(Metrics), - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"metrics", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - arizona_template:from_html( - ~""""" -
-

Auto-refreshes every 2s

- -
-
-
Total Memory
-
{arizona_template:get_binding(total_mem_val, Bindings)}
- - - -
-
-
Process Memory
-
{arizona_template:get_binding(proc_mem_val, Bindings)}
- - - -
-
-
Binary Memory
-
{arizona_template:get_binding(bin_mem_val, Bindings)}
- - - -
-
-
Process Count
-
{arizona_template:get_binding(proc_count_val, Bindings)}
- - - -
-
-
Run Queue
-
{arizona_template:get_binding(run_queue_val, Bindings)}
- - - -
-
-
IO Input/s
-
{arizona_template:get_binding(io_in_val, Bindings)}
- - - -
-
-
IO Output/s
-
{arizona_template:get_binding(io_out_val, Bindings)}
- - - -
-
- -
-
Scheduler Utilization
-
- {arizona_template:render_list(fun(SchedEntry) -> - Id = maps:get(id, SchedEntry), - Util = maps:get(util, SchedEntry), - Pct = integer_to_binary(min(100, round(Util))), - Color = sched_color(Util), - arizona_template:from_html(~""" -
- S{integer_to_binary(Id)} -
-
-
- {Pct}% -
- """) - end, arizona_template:get_binding(sched_util, Bindings))} -
-
-
- """"" - ). - -handle_info(refresh, View) -> - erlang:send_after(2000, self(), refresh), - State = arizona_view:get_state(View), - OldMetrics = arizona_stateful:get_binding(metrics_state, State), - NewMetrics = nova_liveboard_data:collect_metrics(OldMetrics), - NewB = metrics_to_bindings(NewMetrics), - lists:foldl( - fun({K, V}, S) -> - arizona_stateful:put_binding(K, V, S) - end, - State, - maps:to_list(NewB) - ), - UpdatedState = lists:foldl( - fun({K, V}, S) -> - arizona_stateful:put_binding(K, V, S) - end, - State, - maps:to_list(NewB) - ), - {[], arizona_view:update_state(UpdatedState, View)}. - -%% Internal - -metrics_to_bindings(Metrics) -> - TotalMem = queue:to_list(maps:get(total_memory, Metrics)), - ProcMem = queue:to_list(maps:get(process_memory, Metrics)), - BinMem = queue:to_list(maps:get(binary_memory, Metrics)), - ProcCount = queue:to_list(maps:get(process_count, Metrics)), - RunQueue = queue:to_list(maps:get(run_queue, Metrics)), - IoIn = queue:to_list(maps:get(io_input, Metrics)), - IoOut = queue:to_list(maps:get(io_output, Metrics)), - #{ - id => ~"metrics_view", - metrics_state => Metrics, - total_mem_val => nova_liveboard_data:format_bytes(last_val(TotalMem)), - total_mem_pts => nova_liveboard_data:sparkline_points(TotalMem, 200, 40), - proc_mem_val => nova_liveboard_data:format_bytes(last_val(ProcMem)), - proc_mem_pts => nova_liveboard_data:sparkline_points(ProcMem, 200, 40), - bin_mem_val => nova_liveboard_data:format_bytes(last_val(BinMem)), - bin_mem_pts => nova_liveboard_data:sparkline_points(BinMem, 200, 40), - proc_count_val => integer_to_binary(last_val(ProcCount)), - proc_count_pts => nova_liveboard_data:sparkline_points(ProcCount, 200, 40), - run_queue_val => integer_to_binary(last_val(RunQueue)), - run_queue_pts => nova_liveboard_data:sparkline_points(RunQueue, 200, 40), - io_in_val => nova_liveboard_data:format_bytes(last_val(IoIn)), - io_in_pts => nova_liveboard_data:sparkline_points(IoIn, 200, 40), - io_out_val => nova_liveboard_data:format_bytes(last_val(IoOut)), - io_out_pts => nova_liveboard_data:sparkline_points(IoOut, 200, 40), - sched_util => maps:get(scheduler_util, Metrics) - }. - -last_val([]) -> 0; -last_val(List) -> lists:last(List). - -sched_color(Util) when Util > 80 -> ~"bar-fill-red"; -sched_color(Util) when Util > 50 -> ~"bar-fill-amber"; -sched_color(_) -> ~"bar-fill-blue". diff --git a/src/nova_liveboard_page_controller.erl b/src/nova_liveboard_page_controller.erl new file mode 100644 index 0000000..1d61ca7 --- /dev/null +++ b/src/nova_liveboard_page_controller.erl @@ -0,0 +1,187 @@ +-module(nova_liveboard_page_controller). +-moduledoc """ +Nova controllers for the liveboard pages. + +Each page server-renders the full mission-control shell (`nova_liveboard_html: +page/4`) with a first-paint snapshot, then a `data-init` stream takes over and +repaints the live region. `page_inner/2` is the single place that maps a page +to its data + HTML; both these controllers and `nova_liveboard_sse` call it, so +the first paint and the live patches always agree. +""". + +-export([ + index/1, + processes/1, + ets/1, + applications/1, + ports/1, + supervisors/1, + metrics/1, + requests/1, + request_show/1, + database/1, + schemas/1, + page_inner/2, + page_atom/1, + app_names/0, + default_app/1 +]). + +-define(TOP_N, 50). + +%% --------------------------------------------------------------------------- +%% controllers +%% --------------------------------------------------------------------------- + +index(_Req) -> + full(overview, stream_path(overview), page_inner(overview, undefined)). + +ets(_Req) -> + full(ets, stream_path(ets), page_inner(ets, undefined)). + +applications(_Req) -> + full(applications, stream_path(applications), page_inner(applications, undefined)). + +ports(_Req) -> + full(ports, stream_path(ports), page_inner(ports, undefined)). + +metrics(_Req) -> + full(metrics, stream_path(metrics), page_inner(metrics, undefined)). + +database(_Req) -> + full(database, stream_path(database), page_inner(database, undefined)). + +schemas(_Req) -> + full(schemas, stream_path(schemas), page_inner(schemas, undefined)). + +requests(_Req) -> + full(requests, stream_path(requests), page_inner(requests, undefined)). + +processes(Req) -> + Sort = sort_qs(Req), + Stream = <<(stream_path(processes))/binary, "?sort=", (atom_to_binary(Sort))/binary>>, + full(processes, Stream, page_inner(processes, Sort)). + +supervisors(Req) -> + App = app_qs(Req), + Stream = <<(stream_path(supervisors))/binary, "?app=", (atom_to_binary(App))/binary>>, + full(supervisors, Stream, page_inner(supervisors, App)). + +request_show(Req) -> + Id = maps:get(~"id", maps:get(bindings, Req, #{}), ~""), + %% Static page (stream => none): a stored snapshot must not be overwritten. + full(requests, none, nova_liveboard_html:request_detail_html(nova_liveboard_tracer:get(Id))). + +%% --------------------------------------------------------------------------- +%% shared render dispatch +%% --------------------------------------------------------------------------- + +-doc "Map a page to its freshly-gathered data, rendered to the live region inner.". +-spec page_inner(atom(), term()) -> iodata(). +page_inner(overview, _) -> + nova_liveboard_html:overview_html(nova_liveboard_data:system_info()); +page_inner(processes, Sort) -> + nova_liveboard_html:processes_html(nova_liveboard_data:top_processes(Sort, ?TOP_N), Sort); +page_inner(ets, _) -> + nova_liveboard_html:ets_html(nova_liveboard_data:ets_tables()); +page_inner(applications, _) -> + nova_liveboard_html:apps_html(nova_liveboard_data:running_applications()); +page_inner(ports, _) -> + nova_liveboard_html:ports_html(nova_liveboard_data:port_info()); +page_inner(supervisors, App) -> + nova_liveboard_html:sup_tree_html( + app_names(), App, nova_liveboard_data:supervision_tree(App) + ); +page_inner(metrics, _) -> + nova_liveboard_html:metrics_html(nova_liveboard_data:collect_metrics(undefined)); +page_inner(requests, _) -> + nova_liveboard_html:requests_html( + nova_liveboard_tracer:recent(), nova_liveboard_tracer:trace_state(), tracing_enabled() + ); +page_inner(database, _) -> + nova_liveboard_html:database_html(kura_repos()); +page_inner(schemas, _) -> + nova_liveboard_html:schemas_html(kura_schemas()). + +-doc "Binary page name (from a route binding) to its page atom.". +-spec page_atom(binary()) -> atom(). +page_atom(~"overview") -> overview; +page_atom(~"ets") -> ets; +page_atom(~"applications") -> applications; +page_atom(~"ports") -> ports; +page_atom(~"metrics") -> metrics; +page_atom(~"database") -> database; +page_atom(~"schemas") -> schemas; +page_atom(_) -> overview. + +-spec app_names() -> [atom()]. +app_names() -> + [A || {A, _, _} <- application:which_applications()]. + +-spec default_app([atom()]) -> atom(). +default_app(Names) -> + case application:get_env(nova, bootstrap_application) of + {ok, App} -> App; + _ -> first_user_app(Names) + end. + +%% --------------------------------------------------------------------------- +%% internal +%% --------------------------------------------------------------------------- + +full(Active, Stream, Main) -> + Vitals = nova_liveboard_html:vitals_html(nova_liveboard_data:system_info()), + Body = nova_liveboard_html:page(Active, Stream, Vitals, Main), + {status, 200, nova_liveboard_html:csp_headers(), iolist_to_binary(Body)}. + +stream_path(Page) -> + <<(nova_liveboard:prefix())/binary, "/sse/", (atom_to_binary(Page))/binary>>. + +sort_qs(Req) -> + case proplists:get_value(~"sort", cowboy_req:parse_qs(Req)) of + ~"reductions" -> reductions; + ~"message_queue_len" -> message_queue_len; + _ -> memory + end. + +app_qs(Req) -> + Names = app_names(), + case proplists:get_value(~"app", cowboy_req:parse_qs(Req)) of + undefined -> + default_app(Names); + Bin -> + case lists:search(fun(A) -> atom_to_binary(A) =:= Bin end, Names) of + {value, A} -> A; + false -> default_app(Names) + end + end. + +first_user_app(Names) -> + System = [kernel, stdlib, sasl, nova, datastar, cowboy, ranch, crypto], + case [A || A <- Names, not lists:member(A, System)] of + [A | _] -> A; + [] -> nova_liveboard + end. + +%% Tracing is "on" if the plugin is in Nova's config, or the tracer has already +%% captured something (covers runtime registration). +tracing_enabled() -> + plugin_in_config() orelse safe_active(). + +plugin_in_config() -> + lists:any( + fun + ({_Type, Mod, _Opts}) -> Mod =:= nova_liveboard_trace_plugin; + (_) -> false + end, + application:get_env(nova, plugins, []) + ). + +safe_active() -> + whereis(nova_liveboard_tracer) =/= undefined andalso nova_liveboard_tracer:active(). + +kura_repos() -> + [nova_liveboard_data:kura_repo_info(R) || R <- nova_liveboard_data:kura_repos()]. + +kura_schemas() -> + lists:append([nova_liveboard_data:kura_schemas(R) || R <- nova_liveboard_data:kura_repos()]). diff --git a/src/nova_liveboard_ports_view.erl b/src/nova_liveboard_ports_view.erl deleted file mode 100644 index c5ab8a3..0000000 --- a/src/nova_liveboard_ports_view.erl +++ /dev/null @@ -1,68 +0,0 @@ --module(nova_liveboard_ports_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(3000, self(), refresh); - false -> ok - end, - Ports = nova_liveboard_data:port_info(), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"ports_view", - ports => Ports - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"ports", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Ports = arizona_template:get_binding(ports, Bindings), - arizona_template:from_html( - ~"""" -
-

{integer_to_binary(length(Ports))} open ports · Auto-refreshes every 3s

-
- - - - - - - - - - - - {arizona_template:render_list(fun(Port) -> - arizona_template:from_html(~""" - - - - - - - - """) - end, Ports)} - -
PortNameConnectedInputOutput
{maps:get(id, Port)}{maps:get(name, Port)}{maps:get(connected, Port)}{nova_liveboard_data:format_bytes(maps:get(input, Port))}{nova_liveboard_data:format_bytes(maps:get(output, Port))}
-
-
- """" - ). - -handle_info(refresh, View) -> - erlang:send_after(3000, self(), refresh), - Ports = nova_liveboard_data:port_info(), - State = arizona_view:get_state(View), - UpdatedState = arizona_stateful:put_binding(ports, Ports, State), - {[], arizona_view:update_state(UpdatedState, View)}. diff --git a/src/nova_liveboard_processes_view.erl b/src/nova_liveboard_processes_view.erl deleted file mode 100644 index 6ebe650..0000000 --- a/src/nova_liveboard_processes_view.erl +++ /dev/null @@ -1,109 +0,0 @@ --module(nova_liveboard_processes_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_event/3, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(2000, self(), refresh); - false -> ok - end, - SortBy = memory, - Procs = nova_liveboard_data:top_processes(SortBy, 50), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"processes_view", - processes => Procs, - sort_by => SortBy - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"processes", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Procs = arizona_template:get_binding(processes, Bindings), - SortBy = arizona_template:get_binding(sort_by, Bindings), - SortMem = ~"arizona.pushEvent('sort', {by: 'memory'})", - SortRed = ~"arizona.pushEvent('sort', {by: 'reductions'})", - SortMsg = ~"arizona.pushEvent('sort', {by: 'msgq'})", - arizona_template:from_html( - ~"""" -
-

Top 50 processes · Auto-refreshes every 2s

-
- - - - - - - - - - - - - - {arizona_template:render_list(fun(Proc) -> - MsgQ = maps:get(message_queue_len, Proc), - MsgClass = case MsgQ > 100 of true -> ~"text-amber"; false -> ~"" end, - arizona_template:from_html(~""" - - - - - - - - - - """) - end, Procs)} - -
PIDName / RegisteredCurrent Function - - - - - - Status
{maps:get(pid, Proc)}{maps:get(name, Proc)}{maps:get(current_function, Proc)}{nova_liveboard_data:format_bytes(maps:get(memory, Proc))}{nova_liveboard_data:format_number(maps:get(reductions, Proc))}{integer_to_binary(MsgQ)}{maps:get(status, Proc)}
-
-
- """" - ). - -handle_event(~"sort", Params, View) -> - SortBy = - case maps:get(~"by", Params) of - ~"memory" -> memory; - ~"reductions" -> reductions; - ~"msgq" -> message_queue_len; - _ -> memory - end, - Procs = nova_liveboard_data:top_processes(SortBy, 50), - State = arizona_view:get_state(View), - S1 = arizona_stateful:put_binding(sort_by, SortBy, State), - S2 = arizona_stateful:put_binding(processes, Procs, S1), - {[], arizona_view:update_state(S2, View)}. - -handle_info(refresh, View) -> - erlang:send_after(2000, self(), refresh), - State = arizona_view:get_state(View), - SortBy = arizona_stateful:get_binding(sort_by, State), - Procs = nova_liveboard_data:top_processes(SortBy, 50), - UpdatedState = arizona_stateful:put_binding(processes, Procs, State), - {[], arizona_view:update_state(UpdatedState, View)}. - -%% Internal - -sort_class(Current, Col) when Current =:= Col -> ~"active"; -sort_class(_, _) -> ~"". diff --git a/src/nova_liveboard_router.erl b/src/nova_liveboard_router.erl index 57acd57..5376615 100644 --- a/src/nova_liveboard_router.erl +++ b/src/nova_liveboard_router.erl @@ -3,14 +3,44 @@ -export([routes/1]). +%% All pages, the per-region SSE streams, the request-trace actions and the +%% static assets run on Nova's own listener under the configured prefix. The +%% /sse/:page route returns {stream, ...}, held open by nova_liveboard_sse +%% (see that module + novaframework/nova#387). routes(_Env) -> [ #{ - prefix => nova_liveboard:prefix(), + prefix => binary_to_list(nova_liveboard:prefix()), security => false, routes => [ - {~"/", fun nova_liveboard_controller:index/1, #{methods => [get]}}, - {~"/:page", fun nova_liveboard_controller:index/1, #{methods => [get]}}, + {"/", fun nova_liveboard_page_controller:index/1, #{methods => [get]}}, + {"/processes", fun nova_liveboard_page_controller:processes/1, #{methods => [get]}}, + {"/ets", fun nova_liveboard_page_controller:ets/1, #{methods => [get]}}, + {"/applications", fun nova_liveboard_page_controller:applications/1, #{ + methods => [get] + }}, + {"/ports", fun nova_liveboard_page_controller:ports/1, #{methods => [get]}}, + {"/supervisors", fun nova_liveboard_page_controller:supervisors/1, #{ + methods => [get] + }}, + {"/metrics", fun nova_liveboard_page_controller:metrics/1, #{methods => [get]}}, + {"/database", fun nova_liveboard_page_controller:database/1, #{methods => [get]}}, + {"/schemas", fun nova_liveboard_page_controller:schemas/1, #{methods => [get]}}, + {"/requests", fun nova_liveboard_page_controller:requests/1, #{methods => [get]}}, + {"/requests/trace/start", fun nova_liveboard_action_controller:trace_start/1, #{ + methods => [post] + }}, + {"/requests/trace/stop", fun nova_liveboard_action_controller:trace_stop/1, #{ + methods => [post] + }}, + {"/requests/clear", fun nova_liveboard_action_controller:clear/1, #{ + methods => [post] + }}, + {"/requests/:id", fun nova_liveboard_page_controller:request_show/1, #{ + methods => [get] + }}, + {"/sse/:page", fun nova_liveboard_sse:stream/1, #{methods => [get]}}, + {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}, {"/assets/[...]", "static/assets"} ] } diff --git a/src/nova_liveboard_schemas_view.erl b/src/nova_liveboard_schemas_view.erl deleted file mode 100644 index 4f0dfd6..0000000 --- a/src/nova_liveboard_schemas_view.erl +++ /dev/null @@ -1,136 +0,0 @@ --module(nova_liveboard_schemas_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - Repos = nova_liveboard_data:kura_repos(), - Schemas = lists:flatmap( - fun(R) -> - nova_liveboard_data:kura_schemas(R) - end, - Repos - ), - PreparedSchemas = [prepare_schema(S) || S <- Schemas], - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"schemas_view", - schemas => PreparedSchemas - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"schemas", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Schemas = arizona_template:get_binding(schemas, Bindings), - arizona_template:from_html( - ~"""" -
-

{integer_to_binary(length(Schemas))} schemas

- {arizona_template:render_list(fun(Schema) -> - arizona_template:from_html(~""" -
-
- {maps:get(module, Schema)} - {maps:get(table, Schema)} -
- {maps:get(fields_html, Schema)} - {maps:get(assocs_html, Schema)} - {maps:get(indexes_html, Schema)} -
- """) - end, Schemas)} -
- """" - ). - -handle_info(_Msg, View) -> - {[], View}. - -%% Internal - -prepare_schema(Schema) -> - #{ - module => maps:get(module, Schema), - table => maps:get(table, Schema), - fields_html => fields_html(maps:get(fields, Schema)), - assocs_html => assocs_html(maps:get(associations, Schema)), - indexes_html => indexes_html(maps:get(indexes, Schema)) - }. - -fields_html([]) -> - ~""; -fields_html(Fields) -> - Header = - <<"", "", - "">>, - Rows = [field_row(F) || F <- Fields], - Footer = ~"
FieldTypePKVirtual
", - iolist_to_binary([Header, Rows, Footer]). - -field_row(F) -> - PK = bool_badge(maps:get(primary_key, F)), - Virtual = bool_badge(maps:get(virtual, F)), - iolist_to_binary([ - ~"", - maps:get(name, F), - ~"", - maps:get(type, F), - ~"", - PK, - ~"", - Virtual, - ~"" - ]). - -assocs_html([]) -> - ~""; -assocs_html(Assocs) -> - Header = - <<"
Associations
", - "", "", - "">>, - Rows = [assoc_row(A) || A <- Assocs], - Footer = ~"
NameTypeSchema
", - iolist_to_binary([Header, Rows, Footer]). - -assoc_row(A) -> - iolist_to_binary([ - ~"", - maps:get(name, A), - ~"", - maps:get(type, A), - ~"", - maps:get(schema, A), - ~"" - ]). - -indexes_html([]) -> - ~""; -indexes_html(Indexes) -> - Header = - <<"
Indexes
", "", - "", "">>, - Rows = [index_row(I) || I <- Indexes], - Footer = ~"
ColumnsUnique
", - iolist_to_binary([Header, Rows, Footer]). - -index_row(I) -> - Cols = iolist_to_binary(lists:join(~", ", maps:get(columns, I))), - Unique = bool_badge(maps:get(unique, I)), - iolist_to_binary([ - ~"", - Cols, - ~"", - Unique, - ~"" - ]). - -bool_badge(true) -> ~"yes"; -bool_badge(false) -> ~"". diff --git a/src/nova_liveboard_sse.erl b/src/nova_liveboard_sse.erl new file mode 100644 index 0000000..f91c1c5 --- /dev/null +++ b/src/nova_liveboard_sse.erl @@ -0,0 +1,170 @@ +-module(nova_liveboard_sse). +-moduledoc """ +The liveboard's live transport: one long-lived SSE stream per page region. + +`stream/1` is a normal Nova controller returning `{stream, Code, Headers, +Source}`. That tuple is picked up by a return-handler we register +(`handle_stream/3`), which `stream_reply`s the SSE headers and then **holds the +connection**, pushing `datastar:patch_elements/2` frames and never returning - +so Nova's buffered `render_response` (which would reply again and crash) never +runs. On client disconnect the next `stream_body` fails and the request process +exits, dropping any tracer subscription. See novaframework/nova#387. + +Two stream shapes: + +- **polled** (vitals/overview/processes/ets/applications/ports/supervisors/ + metrics): a `receive ... after RefreshMs` timer repaints the region. The + metrics stream threads the `nova_liveboard_data:collect_metrics/1` + accumulator through its loop so sparklines and scheduler deltas build up. +- **event-driven** (requests): subscribes to `nova_liveboard_tracer` and + prepends each request as it completes; no polling. + +The handler is registered as an explicit `fun/3` because current Nova invokes +return-handlers with 3 args, while its `{Mod, Fun}` form wraps to arity 4. +""". + +-export([register/0, stream/1, handle_stream/3]). + +-define(PAGE, ~"#page"). +-define(VITALS, ~"#vitals"). + +-spec register() -> ok | {error, atom()}. +register() -> + nova_handlers:register_handler(stream, fun ?MODULE:handle_stream/3). + +%% Nova controller: GET /sse/:page +stream(Req) -> + Page = maps:get(~"page", maps:get(bindings, Req, #{}), ~"overview"), + Qs = cowboy_req:parse_qs(Req), + {stream, 200, headers(), {Page, Qs}}. + +handle_stream({stream, Code, Headers, Source}, _Callback, Req0) -> + Req = cowboy_req:stream_reply(Code, Headers, Req0), + serve(Source, Req). + +%% --------------------------------------------------------------------------- +%% per-page streams +%% --------------------------------------------------------------------------- + +serve({~"vitals", _Qs}, Req) -> + loop_poll(vitals, undefined, Req); +serve({~"metrics", _Qs}, Req) -> + State = nova_liveboard_data:collect_metrics(undefined), + case send(Req, page_inner(nova_liveboard_html:metrics_html(State))) of + ok -> loop_metrics(State, Req); + stop -> ok + end; +serve({~"requests", _Qs}, Req) -> + ok = nova_liveboard_tracer:subscribe(), + send(Req, page_frame(requests, undefined)), + loop_requests(Req); +serve({~"processes", Qs}, Req) -> + loop_poll(processes, sort(Qs), Req); +serve({~"supervisors", Qs}, Req) -> + loop_poll(supervisors, app(Qs), Req); +serve({Page, _Qs}, Req) -> + loop_poll(nova_liveboard_page_controller:page_atom(Page), undefined, Req). + +loop_poll(Page, Opt, Req) -> + receive + _ -> loop_poll(Page, Opt, Req) + after refresh() -> + case send(Req, region_frame(Page, Opt)) of + ok -> loop_poll(Page, Opt, Req); + stop -> ok + end + end. + +%% Metrics needs a stateful accumulator; carry it through the loop. +loop_metrics(State, Req) -> + receive + after refresh() -> + State1 = nova_liveboard_data:collect_metrics(State), + case send(Req, page_inner(nova_liveboard_html:metrics_html(State1))) of + ok -> loop_metrics(State1, Req); + stop -> ok + end + end. + +loop_requests(Req) -> + receive + {nova_liveboard_request, R} -> + ok = send(Req, prepend_request(R)), + ok = send(Req, toolbar_frame()), + loop_requests(Req); + {nova_liveboard_trace_state, _TS} -> + ok = send(Req, toolbar_frame()), + loop_requests(Req); + nova_liveboard_requests_cleared -> + ok = send(Req, page_frame(requests, undefined)), + loop_requests(Req); + _ -> + loop_requests(Req) + end. + +%% --------------------------------------------------------------------------- +%% frames +%% --------------------------------------------------------------------------- + +region_frame(vitals, _) -> + datastar:patch_elements( + nova_liveboard_html:vitals_html(nova_liveboard_data:system_info()), + #{selector => ?VITALS, mode => inner} + ); +region_frame(Page, Opt) -> + page_frame(Page, Opt). + +page_frame(Page, Opt) -> + page_inner(nova_liveboard_page_controller:page_inner(Page, Opt)). + +page_inner(Html) -> + datastar:patch_elements(Html, #{selector => ?PAGE, mode => inner}). + +prepend_request(R) -> + datastar:patch_elements( + nova_liveboard_html:request_row(R), + #{selector => ~"#req-feed", mode => prepend} + ). + +toolbar_frame() -> + datastar:patch_elements( + nova_liveboard_html:requests_toolbar_html(nova_liveboard_tracer:trace_state()), + #{selector => ~"#req-toolbar", mode => inner} + ). + +%% --------------------------------------------------------------------------- +%% transport + query parsing +%% --------------------------------------------------------------------------- + +send(Req, Frame) -> + try + ok = cowboy_req:stream_body(Frame, nofin, Req), + ok + catch + _:_ -> stop + end. + +headers() -> + maps:from_list(datastar:sse_headers()). + +refresh() -> + nova_liveboard:refresh_ms(). + +sort(Qs) -> + case proplists:get_value(~"sort", Qs) of + ~"reductions" -> reductions; + ~"message_queue_len" -> message_queue_len; + _ -> memory + end. + +app(Qs) -> + Names = nova_liveboard_page_controller:app_names(), + case proplists:get_value(~"app", Qs) of + undefined -> + nova_liveboard_page_controller:default_app(Names); + Bin -> + case lists:search(fun(A) -> atom_to_binary(A) =:= Bin end, Names) of + {value, A} -> A; + false -> nova_liveboard_page_controller:default_app(Names) + end + end. diff --git a/src/nova_liveboard_sup.erl b/src/nova_liveboard_sup.erl new file mode 100644 index 0000000..8c4a241 --- /dev/null +++ b/src/nova_liveboard_sup.erl @@ -0,0 +1,21 @@ +-module(nova_liveboard_sup). +-moduledoc "Top supervisor: owns the request tracer.". +-behaviour(supervisor). + +-export([start_link/0, init/1]). + +-spec start_link() -> {ok, pid()}. +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + Children = [ + #{ + id => nova_liveboard_tracer, + start => {nova_liveboard_tracer, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker + } + ], + {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, Children}}. diff --git a/src/nova_liveboard_sup_view.erl b/src/nova_liveboard_sup_view.erl deleted file mode 100644 index 4335f8a..0000000 --- a/src/nova_liveboard_sup_view.erl +++ /dev/null @@ -1,132 +0,0 @@ --module(nova_liveboard_sup_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_event/3, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(5000, self(), refresh); - false -> ok - end, - Apps = [Name || {Name, _, _} <- lists:sort(application:which_applications())], - SelectedApp = - case Apps of - [First | _] -> First; - [] -> undefined - end, - Tree = fetch_tree(SelectedApp), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"sup_view", - apps => Apps, - selected_app => SelectedApp, - selected_app_name => atom_to_binary(SelectedApp), - flat_tree => flatten_tree(Tree) - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"supervisors", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - arizona_template:from_html( - ~""""" -
-

Auto-refreshes every 5s

- -
- {arizona_template:render_list(fun(App) -> - AppBin = atom_to_binary(App), - BtnClass = case App =:= arizona_template:get_binding(selected_app, Bindings) of - true -> ~"app-btn active"; - false -> ~"app-btn" - end, - OnClick = <<"arizona.pushEvent('select_app', {app: '", AppBin/binary, "'})">>, - arizona_template:from_html(~""" - - """) - end, arizona_template:get_binding(apps, Bindings))} -
- -
-
Supervision Tree — {arizona_template:get_binding(selected_app_name, Bindings)}
-
- {arizona_template:render_list(fun(Node) -> - Name = maps:get(name, Node), - Pid = maps:get(pid, Node), - Type = maps:get(type, Node), - Memory = maps:get(memory, Node, 0), - MsgQ = maps:get(message_queue_len, Node, 0), - Status = maps:get(status, Node, ~"unknown"), - Depth = maps:get(depth, Node), - Indent = integer_to_binary(Depth * 24), - TypeBadge = type_badge(Type), - TypeLabel = type_label(Type), - MsgClass = case MsgQ > 100 of true -> ~"text-amber"; false -> ~"" end, - arizona_template:from_html(~""" -
- {TypeLabel} - {Name} - - {Pid} - {nova_liveboard_data:format_bytes(Memory)} - msgq: {integer_to_binary(MsgQ)} - {Status} - -
- """) - end, arizona_template:get_binding(flat_tree, Bindings))} -
-
-
- """"" - ). - -handle_event(~"select_app", Params, View) -> - AppBin = maps:get(~"app", Params), - App = binary_to_existing_atom(AppBin), - Tree = fetch_tree(App), - State = arizona_view:get_state(View), - S1 = arizona_stateful:put_binding(selected_app, App, State), - S2 = arizona_stateful:put_binding(selected_app_name, AppBin, S1), - S3 = arizona_stateful:put_binding(flat_tree, flatten_tree(Tree), S2), - {[], arizona_view:update_state(S3, View)}. - -handle_info(refresh, View) -> - erlang:send_after(5000, self(), refresh), - State = arizona_view:get_state(View), - App = arizona_stateful:get_binding(selected_app, State), - Tree = fetch_tree(App), - UpdatedState = arizona_stateful:put_binding(flat_tree, flatten_tree(Tree), State), - {[], arizona_view:update_state(UpdatedState, View)}. - -%% Internal - -fetch_tree(undefined) -> - []; -fetch_tree(App) -> - case nova_liveboard_data:supervision_tree(App) of - {ok, Tree} -> Tree; - {error, _} -> [] - end. - -flatten_tree(Nodes) -> - lists:reverse(flatten_tree(Nodes, 0, [])). - -flatten_tree([], _Depth, Acc) -> - Acc; -flatten_tree([#{children := Children} = Node | Rest], Depth, Acc) -> - FlatNode = Node#{depth => Depth}, - Acc1 = flatten_tree(Children, Depth + 1, [FlatNode | Acc]), - flatten_tree(Rest, Depth, Acc1). - -type_badge(supervisor) -> ~"badge badge-blue"; -type_badge(_) -> ~"badge badge-green". - -type_label(supervisor) -> ~"sup"; -type_label(_) -> ~"worker". diff --git a/src/nova_liveboard_system_view.erl b/src/nova_liveboard_system_view.erl deleted file mode 100644 index 5bc5ffe..0000000 --- a/src/nova_liveboard_system_view.erl +++ /dev/null @@ -1,153 +0,0 @@ --module(nova_liveboard_system_view). --behaviour(arizona_view). --compile({parse_transform, arizona_parse_transform}). - --export([mount/2, render/1, handle_info/2]). - -mount(_Arg, _Req) -> - case arizona_live:is_connected(self()) of - true -> erlang:send_after(2000, self(), refresh); - false -> ok - end, - Info = nova_liveboard_data:system_info(), - Prefix = nova_liveboard:prefix(), - Bindings = #{ - id => ~"system_view", - info => Info - }, - Layout = - {nova_liveboard_layout, render, main_content, #{ - active_page => ~"system", - prefix => Prefix, - ws_path => <<(arizona_nova:prefix())/binary, "/live">>, - arizona_prefix => arizona_nova:prefix() - }}, - arizona_view:new(?MODULE, Bindings, Layout). - -render(Bindings) -> - Info = arizona_template:get_binding(info, Bindings), - Mem = maps:get(memory, Info), - arizona_template:from_html( - ~""" -
-

Auto-refreshes every 2s

- -
-
-
OTP Release
-
{maps:get(otp_release, Info)}
-
ERTS {maps:get(erts_version, Info)}
-
-
-
Uptime
-
{nova_liveboard_data:format_uptime(maps:get(uptime, Info))}
-
{maps:get(system_architecture, Info)}
-
-
-
Schedulers
-
{integer_to_binary(maps:get(scheduler_online, Info))}
-
of {integer_to_binary(maps:get(scheduler_count, Info))} available
-
-
-
Total Memory
-
{nova_liveboard_data:format_bytes(maps:get(total, Mem))}
-
-
- -
-
-
Processes
-
{nova_liveboard_data:format_number(maps:get(process_count, Info))}
-
limit: {nova_liveboard_data:format_number(maps:get(process_limit, Info))}
-
-
-
-
-
-
Atoms
-
{nova_liveboard_data:format_number(maps:get(atom_count, Info))}
-
limit: {nova_liveboard_data:format_number(maps:get(atom_limit, Info))}
-
-
-
-
-
-
Ports
-
{nova_liveboard_data:format_number(maps:get(port_count, Info))}
-
limit: {nova_liveboard_data:format_number(maps:get(port_limit, Info))}
-
-
-
-
-
-
ETS Tables
-
{integer_to_binary(maps:get(ets_count, Info))}
-
-
- -
-
Memory Breakdown
- - - - - - - - - - - {render_mem_row(~"Processes", maps:get(processes_used, Mem), maps:get(total, Mem))} - {render_mem_row(~"Binary", maps:get(binary, Mem), maps:get(total, Mem))} - {render_mem_row(~"Code", maps:get(code, Mem), maps:get(total, Mem))} - {render_mem_row(~"ETS", maps:get(ets, Mem), maps:get(total, Mem))} - {render_mem_row(~"Atom", maps:get(atom_used, Mem), maps:get(total, Mem))} - {render_mem_row(~"System", maps:get(system, Mem), maps:get(total, Mem))} - -
TypeSize% of TotalUsage
-
-
- """ - ). - -handle_info(refresh, View) -> - erlang:send_after(2000, self(), refresh), - Info = nova_liveboard_data:system_info(), - State = arizona_view:get_state(View), - UpdatedState = arizona_stateful:put_binding(info, Info, State), - {[], arizona_view:update_state(UpdatedState, View)}. - -%% Internal - -usage_pct(Used, Limit) when Limit > 0 -> - Pct = (Used * 100) div Limit, - integer_to_binary(min(100, Pct)); -usage_pct(_, _) -> - ~"0". - -render_mem_row(Label, Value, Total) -> - Pct = - case Total of - 0 -> 0; - _ -> (Value * 100) div Total - end, - Color = - if - Pct > 60 -> ~"bar-fill-red"; - Pct > 30 -> ~"bar-fill-amber"; - true -> ~"bar-fill-blue" - end, - arizona_template:from_html( - ~""" - - {Label} - {nova_liveboard_data:format_bytes(Value)} - {integer_to_binary(Pct)}% - -
-
-
- - - """ - ). diff --git a/src/nova_liveboard_trace_plugin.erl b/src/nova_liveboard_trace_plugin.erl new file mode 100644 index 0000000..e257c6d --- /dev/null +++ b/src/nova_liveboard_trace_plugin.erl @@ -0,0 +1,152 @@ +-module(nova_liveboard_trace_plugin). +-moduledoc """ +Nova plugin that feeds the liveboard's request tracer. + +Register it as a global plugin in your Nova config to capture every request +handled by the node: + + {nova, [ + {plugins, [ + {pre_request, nova_liveboard_trace_plugin, #{}}, + {post_request, nova_liveboard_trace_plugin, #{}} + ]} + ]}. + +`pre_request/4` stamps the request with an id, start time, reductions baseline +and the resolved handler MFA (from `Env#{callback}`), and - when deep trace is +armed - turns on scoped process tracing of the handling process. +`post_request/4` computes duration / reductions / peak memory / status and +hands the summary to `nova_liveboard_tracer`. The whole chain runs in one +process, so the marker stashed in `Req` survives from pre to post. + +The dashboard's own routes are skipped so it never traces itself (its SSE +streams never return, and would otherwise never finalise). +""". + +-behaviour(nova_plugin). + +-export([init/0, pre_request/4, post_request/4, plugin_info/0]). + +-define(MARK, '$nova_liveboard_trace'). + +-spec init() -> {ok, undefined}. +init() -> + {ok, undefined}. + +pre_request(Req, Env, _Opts, State) -> + case skip(Req) of + true -> + {ok, Req, State}; + false -> + ReqId = new_id(), + Traced = arm(ReqId), + Mark = #{ + id => ReqId, + t0 => erlang:monotonic_time(microsecond), + reds0 => reductions(), + method => cowboy_req:method(Req), + path => cowboy_req:path(Req), + handler => handler_mfa(Env), + traced => Traced + }, + {ok, Req#{?MARK => Mark}, State} + end. + +post_request(Req, _Env, _Opts, State) -> + case maps:get(?MARK, Req, undefined) of + undefined -> + {ok, Req, State}; + Mark -> + finalize(Req, Mark), + {ok, Req, State} + end. + +plugin_info() -> + #{ + title => ~"Nova Liveboard request tracer", + version => ~"1.0.0", + url => ~"https://github.com/novaframework/nova_liveboard", + authors => [~"nova_liveboard"], + description => + ~"Captures per-request timing, reductions and (in deep mode) the tree of processes spawned while handling each request.", + options => [] + }. + +%% --------------------------------------------------------------------------- +%% internal +%% --------------------------------------------------------------------------- + +finalize(Req, Mark) -> + #{id := ReqId, t0 := T0, reds0 := Reds0, traced := Traced} = Mark, + Traced andalso untrace_self(), + Summary = #{ + id => ReqId, + method => maps:get(method, Mark), + path => maps:get(path, Mark), + handler => maps:get(handler, Mark), + status => maps:get(resp_status_code, Req, undefined), + duration_us => erlang:monotonic_time(microsecond) - T0, + reductions => max(0, reductions() - Reds0), + mem => mem(), + ts => erlang:system_time(millisecond) + }, + nova_liveboard_tracer:finalize(ReqId, Summary). + +untrace_self() -> + try + erlang:trace(self(), false, [procs, set_on_spawn]) + catch + _:_ -> 0 + end. + +%% Arm scoped tracing of the current (request-handling) process when the +%% tracer says deep trace is on. set_on_spawn makes any process this one +%% spawns inherit the trace, so the whole spawned subtree is captured. +arm(ReqId) -> + case whereis(nova_liveboard_tracer) of + undefined -> + false; + Tracer -> + case nova_liveboard_tracer:maybe_arm(ReqId, self()) of + trace -> + erlang:trace(self(), true, [procs, set_on_spawn, {tracer, Tracer}]), + true; + no_trace -> + false + end + end. + +skip(Req) -> + whereis(nova_liveboard_tracer) =:= undefined orelse + own_path(cowboy_req:path(Req)). + +own_path(Path) -> + Prefix = nova_liveboard:prefix(), + case binary:match(Path, Prefix) of + {0, _} -> true; + _ -> false + end. + +handler_mfa(#{callback := Fun}) when is_function(Fun) -> + Info = erlang:fun_info(Fun), + M = proplists:get_value(module, Info, unknown), + F = proplists:get_value(name, Info, unknown), + A = proplists:get_value(arity, Info, 0), + iolist_to_binary(io_lib:format("~s:~s/~b", [M, F, A])); +handler_mfa(#{callback := {M, F}}) -> + iolist_to_binary(io_lib:format("~s:~s", [M, F])); +handler_mfa(_) -> + ~"unknown". + +reductions() -> + {reductions, R} = erlang:process_info(self(), reductions), + R. + +mem() -> + case erlang:process_info(self(), memory) of + {memory, M} -> M; + undefined -> 0 + end. + +new_id() -> + string:lowercase(binary:encode_hex(crypto:strong_rand_bytes(6))). diff --git a/src/nova_liveboard_tracer.erl b/src/nova_liveboard_tracer.erl new file mode 100644 index 0000000..8a57a82 --- /dev/null +++ b/src/nova_liveboard_tracer.erl @@ -0,0 +1,246 @@ +-module(nova_liveboard_tracer). +-moduledoc """ +Request store and deep-trace collector for the liveboard. + +Two jobs in one `gen_server`: + +1. **Store** - a capped, newest-first ring of recently completed requests + (`nova_liveboard:request_buffer/0` deep). `nova_liveboard_trace_plugin` + calls `finalize/2` from each request's `post_request` hook; SSE feeds + `subscribe/0` to it for live updates. + +2. **Trace collector** - when deep trace is armed (`start_trace/1`), the next + N requests have their handling process traced with + `erlang:trace(self(), true, [procs, set_on_spawn, {tracer, Pid}])` (the + plugin enables it; this server is the `Pid`). It receives + `{trace, Parent, spawn, Child, MFA}` messages, attributes each spawned + process to the owning request by walking the parent chain, and attaches the + resulting spawned-process tree to the request record on `finalize/2`. + +The dashboard works without any of this; only the Requests page needs the +plugin registered. +""". + +-behaviour(gen_server). + +-export([ + start_link/0, + maybe_arm/2, + finalize/2, + recent/0, + get/1, + clear/0, + subscribe/0, + start_trace/1, + stop_trace/0, + trace_state/0, + active/0 +]). + +-export([init/1, handle_call/3, handle_cast/2, handle_info/2]). + +-define(SERVER, ?MODULE). + +-record(state, { + requests = [] :: [map()], + cap = 200 :: pos_integer(), + armed = 0 :: non_neg_integer(), + active = false :: boolean(), + subs = #{} :: #{reference() => pid()}, + %% in-flight deep traces, keyed by request id + traces = #{} :: #{binary() => trace_acc()}, + %% any currently-traced pid -> the request id that owns it + pid2req = #{} :: #{pid() => binary()} +}). + +-type trace_acc() :: #{nodes := [map()], depths := #{pid() => non_neg_integer()}}. + +%% --------------------------------------------------------------------------- +%% api +%% --------------------------------------------------------------------------- + +-spec start_link() -> {ok, pid()}. +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +-doc """ +Ask whether the request `ReqId` handled by `RootPid` should be deep-traced. + +Returns `trace` (and registers the root pid) when deep trace is armed, else +`no_trace`. The caller (the request process) is responsible for calling +`erlang:trace/3` on itself when `trace` is returned. +""". +-spec maybe_arm(binary(), pid()) -> trace | no_trace. +maybe_arm(ReqId, RootPid) -> + gen_server:call(?SERVER, {maybe_arm, ReqId, RootPid}). + +-doc "Record a completed request; attaches the spawned tree if it was traced.". +-spec finalize(binary(), map()) -> ok. +finalize(ReqId, Summary) -> + gen_server:cast(?SERVER, {finalize, ReqId, Summary}). + +-spec recent() -> [map()]. +recent() -> gen_server:call(?SERVER, recent). + +-spec get(binary()) -> map() | undefined. +get(ReqId) -> gen_server:call(?SERVER, {get, ReqId}). + +-spec clear() -> ok. +clear() -> gen_server:cast(?SERVER, clear). + +-doc "Register the caller for live request + trace-state notifications.". +-spec subscribe() -> ok. +subscribe() -> gen_server:call(?SERVER, {subscribe, self()}). + +-spec start_trace(pos_integer()) -> ok. +start_trace(N) -> gen_server:cast(?SERVER, {start_trace, N}). + +-spec stop_trace() -> ok. +stop_trace() -> gen_server:cast(?SERVER, stop_trace). + +-spec trace_state() -> map(). +trace_state() -> gen_server:call(?SERVER, trace_state). + +-doc "Whether any request has been captured (a proxy for 'plugin is wired up').". +-spec active() -> boolean(). +active() -> gen_server:call(?SERVER, active). + +%% --------------------------------------------------------------------------- +%% gen_server +%% --------------------------------------------------------------------------- + +init([]) -> + {ok, #state{cap = nova_liveboard:request_buffer()}}. + +handle_call({maybe_arm, ReqId, RootPid}, _From, #state{armed = N} = S) when N > 0 -> + Acc = #{nodes => [], depths => #{RootPid => 0}}, + S1 = S#state{ + armed = N - 1, + active = true, + traces = (S#state.traces)#{ReqId => Acc}, + pid2req = (S#state.pid2req)#{RootPid => ReqId} + }, + {reply, trace, notify_trace_state(S1)}; +handle_call({maybe_arm, _ReqId, _RootPid}, _From, S) -> + {reply, no_trace, S#state{active = true}}; +handle_call(recent, _From, S) -> + {reply, S#state.requests, S}; +handle_call({get, ReqId}, _From, S) -> + Found = + case lists:search(fun(R) -> maps:get(id, R) =:= ReqId end, S#state.requests) of + {value, R} -> R; + false -> undefined + end, + {reply, Found, S}; +handle_call({subscribe, Pid}, _From, S) -> + Ref = erlang:monitor(process, Pid), + {reply, ok, S#state{subs = (S#state.subs)#{Ref => Pid}}}; +handle_call(trace_state, _From, S) -> + {reply, trace_state_map(S), S}; +handle_call(active, _From, S) -> + {reply, S#state.active, S}; +handle_call(_Req, _From, S) -> + {reply, ok, S}. + +handle_cast({finalize, ReqId, Summary}, S) -> + {Tree, S1} = take_trace(ReqId, S), + Record = Summary#{spawned => length(Tree), spawned_tree => Tree}, + Reqs = lists:sublist([Record | S1#state.requests], S1#state.cap), + S2 = S1#state{requests = Reqs, active = true}, + notify(S2, {nova_liveboard_request, Record}), + {noreply, S2}; +handle_cast(clear, S) -> + notify(S, nova_liveboard_requests_cleared), + {noreply, S#state{requests = []}}; +handle_cast({start_trace, N}, S) -> + {noreply, notify_trace_state(S#state{armed = N})}; +handle_cast(stop_trace, S) -> + {noreply, notify_trace_state(S#state{armed = 0})}; +handle_cast(_Msg, S) -> + {noreply, S}. + +handle_info({trace, Parent, spawn, Child, MFA}, S) -> + {noreply, record_spawn(Parent, Child, MFA, S)}; +handle_info({'DOWN', Ref, process, _Pid, _Reason}, S) -> + {noreply, S#state{subs = maps:remove(Ref, S#state.subs)}}; +handle_info(_Msg, S) -> + {noreply, S}. + +%% --------------------------------------------------------------------------- +%% trace bookkeeping +%% --------------------------------------------------------------------------- + +record_spawn(Parent, Child, MFA, S) -> + case maps:find(Parent, S#state.pid2req) of + {ok, ReqId} -> + Acc = maps:get(ReqId, S#state.traces), + Depths = maps:get(depths, Acc), + Depth = maps:get(Parent, Depths, 0) + 1, + Node = #{ + pid => pid_bin(Child), + mfa => format_mfa(MFA), + parent => pid_bin(Parent), + depth => Depth + }, + Acc1 = Acc#{ + nodes => [Node | maps:get(nodes, Acc)], + depths => Depths#{Child => Depth} + }, + S#state{ + traces = (S#state.traces)#{ReqId => Acc1}, + pid2req = (S#state.pid2req)#{Child => ReqId} + }; + error -> + S + end. + +take_trace(ReqId, S) -> + case maps:take(ReqId, S#state.traces) of + {Acc, Traces1} -> + Pids = maps:keys(maps:get(depths, Acc)), + untrace(Pids), + Pid2Req1 = maps:without(Pids, S#state.pid2req), + Tree = lists:reverse(maps:get(nodes, Acc)), + {Tree, S#state{traces = Traces1, pid2req = Pid2Req1}}; + error -> + {[], S} + end. + +untrace(Pids) -> + lists:foreach( + fun(Pid) -> + try + erlang:trace(Pid, false, [procs, set_on_spawn]) + catch + _:_ -> 0 + end + end, + Pids + ). + +%% --------------------------------------------------------------------------- +%% notifications +%% --------------------------------------------------------------------------- + +notify(#state{subs = Subs}, Msg) -> + maps:foreach(fun(_Ref, Pid) -> Pid ! Msg end, Subs). + +notify_trace_state(S) -> + notify(S, {nova_liveboard_trace_state, trace_state_map(S)}), + S. + +trace_state_map(#state{armed = N}) -> + #{armed => N > 0, remaining => N}. + +%% --------------------------------------------------------------------------- +%% helpers +%% --------------------------------------------------------------------------- + +pid_bin(Pid) -> list_to_binary(pid_to_list(Pid)). + +format_mfa({M, F, Args}) when is_list(Args) -> + iolist_to_binary(io_lib:format("~s:~s/~b", [M, F, length(Args)])); +format_mfa({M, F, A}) when is_integer(A) -> + iolist_to_binary(io_lib:format("~s:~s/~b", [M, F, A])); +format_mfa(Other) -> + iolist_to_binary(io_lib:format("~p", [Other])). diff --git a/test/nova_liveboard_data_tests.erl b/test/nova_liveboard_data_tests.erl new file mode 100644 index 0000000..7e10e9c --- /dev/null +++ b/test/nova_liveboard_data_tests.erl @@ -0,0 +1,64 @@ +-module(nova_liveboard_data_tests). +-include_lib("eunit/include/eunit.hrl"). + +format_bytes_test_() -> + [ + ?_assertEqual(~"512 B", nova_liveboard_data:format_bytes(512)), + ?_assertEqual(~"1.0 KB", nova_liveboard_data:format_bytes(1024)), + ?_assertEqual(~"1.0 MB", nova_liveboard_data:format_bytes(1048576)), + ?_assertEqual(~"1.0 GB", nova_liveboard_data:format_bytes(1073741824)) + ]. + +format_number_test_() -> + [ + ?_assertEqual(~"42", nova_liveboard_data:format_number(42)), + ?_assertEqual(~"1.5K", nova_liveboard_data:format_number(1500)), + ?_assertEqual(~"2.0M", nova_liveboard_data:format_number(2000000)) + ]. + +format_uptime_test_() -> + [ + ?_assertEqual(~"00:00:05", nova_liveboard_data:format_uptime(5000)), + ?_assertEqual(~"00:01:00", nova_liveboard_data:format_uptime(60000)), + ?_assertEqual(~"1d 00:00:00", nova_liveboard_data:format_uptime(86400000)) + ]. + +sparkline_points_test_() -> + [ + ?_assertEqual(~"", nova_liveboard_data:sparkline_points([], 200, 40)), + %% a single point spans the full width at mid-height + ?_assertEqual(~"0,20.0 200.0,20.0", nova_liveboard_data:sparkline_points([5], 200, 40)), + ?_assert(is_binary(nova_liveboard_data:sparkline_points([1, 5, 3, 9, 2], 200, 40))) + ]. + +system_info_shape_test() -> + Sys = nova_liveboard_data:system_info(), + ?assert(is_integer(maps:get(process_count, Sys))), + ?assert(is_integer(maps:get(process_limit, Sys))), + ?assert(is_map(maps:get(memory, Sys))), + ?assert(maps:get(process_count, Sys) =< maps:get(process_limit, Sys)). + +top_processes_shape_test() -> + Procs = nova_liveboard_data:top_processes(memory, 5), + ?assert(length(Procs) =< 5), + ?assert(lists:all(fun(P) -> is_binary(maps:get(pid, P)) end, Procs)), + %% sorted descending by the requested key + Mems = [maps:get(memory, P) || P <- Procs], + ?assertEqual(lists:reverse(lists:sort(Mems)), Mems). + +ets_and_apps_and_ports_shape_test() -> + ?assert(is_list(nova_liveboard_data:ets_tables())), + Apps = nova_liveboard_data:running_applications(), + ?assert(lists:any(fun(A) -> maps:get(name, A) =:= ~"kernel" end, Apps)), + ?assert(is_list(nova_liveboard_data:port_info())). + +supervision_tree_test() -> + {ok, Tree} = nova_liveboard_data:supervision_tree(kernel), + ?assert(is_list(Tree)), + ?assertMatch({error, _}, nova_liveboard_data:supervision_tree(no_such_app_xyz)). + +collect_metrics_accumulates_test() -> + S0 = nova_liveboard_data:collect_metrics(undefined), + S1 = nova_liveboard_data:collect_metrics(S0), + ?assertEqual(2, queue:len(maps:get(total_memory, S1))), + ?assert(is_list(maps:get(scheduler_util, S1))). diff --git a/test/nova_liveboard_html_tests.erl b/test/nova_liveboard_html_tests.erl new file mode 100644 index 0000000..23f3aaa --- /dev/null +++ b/test/nova_liveboard_html_tests.erl @@ -0,0 +1,147 @@ +-module(nova_liveboard_html_tests). +-include_lib("eunit/include/eunit.hrl"). + +bin(IoData) -> iolist_to_binary(IoData). + +contains(Hay, Needle) -> binary:match(bin(Hay), Needle) =/= nomatch. + +count(Hay, Needle) -> length(binary:matches(bin(Hay), Needle)). + +%% ---- escaping ---- + +html_escape_test_() -> + [ + ?_assertEqual(~"&<>"", nova_liveboard_html:html_escape(~"&<>\"")), + ?_assertEqual(~"plain", nova_liveboard_html:html_escape(~"plain")), + ?_assertEqual(~"42", nova_liveboard_html:html_escape(42)), + ?_assertEqual(~"an_atom", nova_liveboard_html:html_escape(an_atom)) + ]. + +escape_blocks_injection_test() -> + Out = bin(nova_liveboard_html:html_escape(~"")), + ?assertNot(contains(Out, ~"