Add an /rvrb page showing the rvrb bot's status and stats
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-09-07 15:00 UTC
- commit
- 4d2a8d9c2cdb403632d953b24a6b298e63b8a89e
- parent
- cf1425bd88
- tree
- browse at this commit
14 files changed +1059 -1
| Blog/Blog.csproj | +8 -0 | |
| Blog/Components/Layout/SiteFooter.razor | +1 -0 | |
| Blog/Components/Pages/Rvrb.razor | added | +264 -0 |
| Blog/Components/Pages/Rvrb.razor.cs | added | +63 -0 |
| Blog/Components/Pages/Rvrb.razor.js | added | +106 -0 |
| Blog/Components/_Shared/Timestamp.razor | added | +12 -0 |
| Blog/Format.cs | added | +26 -0 |
| Blog/Models/RvrbSnapshot.cs | added | +121 -0 |
| Blog/Program.cs | +5 -0 | |
| Blog/Services/RvrbService.cs | added | +154 -0 |
| Blog/Services/RvrbSnapshotReader.cs | added | +155 -0 |
| Blog/appsettings.json | +5 -1 | |
| Blog/wwwroot/app.css | +119 -0 | |
| CLAUDE.md | +20 -0 |
Blog/Blog.csproj +8 -0
| @@ -12,6 +12,14 @@ | ||
| 12 | 12 | </ItemGroup> |
| 13 | 13 | |
| 14 | 14 | <ItemGroup> |
| 15 | + <!-- | |
| 16 | + The /rvrb page reads the rvrb bot's stats over Erlang distribution. BeamSharp is not on | |
| 17 | + NuGet yet, so this points at the checkout next door rather than at a package version. | |
| 18 | + --> | |
| 19 | + <ProjectReference Include="..\..\BeamSharp\src\BeamSharp\BeamSharp.csproj" /> | |
| 20 | + </ItemGroup> | |
| 21 | + | |
| 22 | + <ItemGroup> | |
| 15 | 23 | <Folder Include="wwwroot\Icons\" /> |
| 16 | 24 | </ItemGroup> |
| 17 | 25 | |
Blog/Components/Layout/SiteFooter.razor +1 -0
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | <li><NavLink href="/BRPTestData">BRP Test Data</NavLink></li> |
| 12 | 12 | <li><NavLink href="/Query">Query</NavLink></li> |
| 13 | 13 | <li><NavLink href="/Storage">Storage</NavLink></li> |
| 14 | + <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> | |
| 14 | 15 | <li><NavLink href="https://git.bes.is">Git</NavLink></li> |
| 15 | 16 | <!-- <li><a href="/webrtc.html">WebRTC</a></li> --> |
| 16 | 17 | <!-- <li><a href="/spotify/index.html">Spotify</a></li> --> |
Blog/Components/Pages/Rvrb.razor +264 -0
| @@ -0,0 +1,264 @@ | ||
| 1 | + "/rvrb" | |
| 2 | +<PageTitle>rvrb bot</PageTitle> | |
| 3 | +<PageScript Src="./Components/Pages/Rvrb.razor.js"></PageScript> | |
| 4 | + | |
| 5 | +<main> | |
| 6 | + <h1>rvrb bot</h1> | |
| 7 | + | |
| 8 | + <p class="flex-spread"> | |
| 9 | + <span class="@StatusClass"></span> | |
| 10 | + <label class="noselect"> | |
| 11 | + Auto-refresh | |
| 12 | + <input type="checkbox" id="autoRefresh"> | |
| 13 | + </label> | |
| 14 | + </p> | |
| 15 | + | |
| 16 | + (Snapshot is null) | |
| 17 | + { | |
| 18 | + <Panel Legend="Unreachable"> | |
| 19 | + <p> | |
| 20 | + Nothing answered on the bot's node, so there are no stats to show. | |
| 21 | + </p> | |
| 22 | + (Status.Error is { } error) | |
| 23 | + { | |
| 24 | + @* A failure on the far side arrives as an Erlang term, stack trace and all, which | |
| 25 | + is worth keeping but not worth putting in front of everyone. *@ | |
| 26 | + <details> | |
| 27 | + <summary class="status-down">What went wrong</summary> | |
| 28 | + <pre></pre> | |
| 29 | + </details> | |
| 30 | + } | |
| 31 | + </Panel> | |
| 32 | + } | |
| 33 | + else | |
| 34 | + { | |
| 35 | + <Panel Legend="Now playing"> | |
| 36 | + (Snapshot.Live is null) | |
| 37 | + { | |
| 38 | + <p>The bot isn't connected to RVRB right now, so nothing is playing.</p> | |
| 39 | + } | |
| 40 | + else if (Snapshot.Live.CurrentTrack is not { } track) | |
| 41 | + { | |
| 42 | + <p>Connected, but no track has played since the bot joined.</p> | |
| 43 | + } | |
| 44 | + else | |
| 45 | + { | |
| 46 | + <div class="now-playing"> | |
| 47 | + (track.AlbumArt is { } art) | |
| 48 | + { | |
| 49 | + <img class="album-art" src="@art" alt="" width="128" height="128"/> | |
| 50 | + } | |
| 51 | + <div> | |
| 52 | + <h3></h3> | |
| 53 | + <p>(", ", track.ArtistNames)</p> | |
| 54 | + @* A bar needs both halves to mean anything: a track that came without a | |
| 55 | + duration, or one already playing when the bot connected, gets neither. *@ | |
| 56 | + (track.Duration is { } length && track.Elapsed is not null) | |
| 57 | + { | |
| 58 | + <p> | |
| 59 | + <progress id="trackProgress" max="1000" | |
| 60 | + value="@((int)((track.Progress ?? 0) * 1000))" | |
| 61 | + data-elapsed-ms="@((long)track.Elapsed.Value.TotalMilliseconds)" | |
| 62 | + data-duration-ms="@((long)length.TotalMilliseconds)" | |
| 63 | + data-age-ms="@((long)Age.TotalMilliseconds)"></progress> | |
| 64 | + <span id="trackElapsed">(track.Elapsed)</span> | |
| 65 | + / | |
| 66 | + <span>(length)</span> | |
| 67 | + </p> | |
| 68 | + } | |
| 69 | + <p> | |
| 70 | + 👍 | |
| 71 | + 🔖 | |
| 72 | + (Snapshot.Live.AutoDoped) | |
| 73 | + { | |
| 74 | + <span class="status-up"> — the bot doped this one</span> | |
| 75 | + } | |
| 76 | + (Snapshot.Live.AutoStarred) | |
| 77 | + { | |
| 78 | + <span class="status-up"> — and starred it</span> | |
| 79 | + } | |
| 80 | + </p> | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + } | |
| 84 | + </Panel> | |
| 85 | + | |
| 86 | + (Snapshot.Live is { Djs.Count: > 0 } live) | |
| 87 | + { | |
| 88 | + <Panel Legend="@($"DJ queue — one lap ≈ {Format.Duration(live.Lap)}")"> | |
| 89 | + <div class="table-scroll"> | |
| 90 | + <table> | |
| 91 | + <thead> | |
| 92 | + <tr> | |
| 93 | + <th>DJ</th> | |
| 94 | + <th>Avg track length</th> | |
| 95 | + <th>Next play in</th> | |
| 96 | + </tr> | |
| 97 | + </thead> | |
| 98 | + <tbody> | |
| 99 | + (var dj in live.Djs) | |
| 100 | + { | |
| 101 | + <tr> | |
| 102 | + <td>@(dj.Current ? "▶ " : "")@(dj.Name ?? "someone")</td> | |
| 103 | + <td> | |
| 104 | + ≈ (dj.AverageTrack) | |
| 105 | + (!dj.Measured) | |
| 106 | + { | |
| 107 | + <span class="muted"> (assumed)</span> | |
| 108 | + } | |
| 109 | + </td> | |
| 110 | + <td>≈ (dj.Wait)</td> | |
| 111 | + </tr> | |
| 112 | + } | |
| 113 | + </tbody> | |
| 114 | + </table> | |
| 115 | + </div> | |
| 116 | + (live.QueuedTracks > 0) | |
| 117 | + { | |
| 118 | + <p> @(live.QueuedTracks == 1 ? "track" : "tracks") queued for the bot's own turn.</p> | |
| 119 | + } | |
| 120 | + </Panel> | |
| 121 | + } | |
| 122 | + | |
| 123 | + <Panel Legend="Totals"> | |
| 124 | + <dl class="stats"> | |
| 125 | + <div><dt>Plays</dt><dd></dd></div> | |
| 126 | + <div><dt>Tracks</dt><dd></dd></div> | |
| 127 | + <div><dt>Artists</dt><dd></dd></div> | |
| 128 | + <div><dt>DJs</dt><dd></dd></div> | |
| 129 | + <div><dt>Users seen</dt><dd></dd></div> | |
| 130 | + <div><dt>Dopes</dt><dd></dd></div> | |
| 131 | + <div><dt>Stars</dt><dd></dd></div> | |
| 132 | + </dl> | |
| 133 | + (Snapshot.Totals.FirstPlayAt is { } first) | |
| 134 | + { | |
| 135 | + <p>Recording plays since <Timestamp At="first"/>.</p> | |
| 136 | + } | |
| 137 | + </Panel> | |
| 138 | + | |
| 139 | + <Panel Legend="@($"Plays per day — last {Snapshot.PlaysPerDay.Count} days")"> | |
| 140 | + <ol class="bars"> | |
| 141 | + (var day in Snapshot.PlaysPerDay) | |
| 142 | + { | |
| 143 | + <li title="@BarTitle(day)"> | |
| 144 | + <span class="bar" style="block-size: @(BarHeight(day.Plays))%"></span> | |
| 145 | + <span class="bar-label"></span> | |
| 146 | + </li> | |
| 147 | + } | |
| 148 | + </ol> | |
| 149 | + </Panel> | |
| 150 | + | |
| 151 | + <Panel Legend="Top DJs"> | |
| 152 | + <div class="table-scroll"> | |
| 153 | + <table> | |
| 154 | + <thead> | |
| 155 | + <tr> | |
| 156 | + <th>DJ</th> | |
| 157 | + <th>Plays</th> | |
| 158 | + <th>👍</th> | |
| 159 | + <th>🔖</th> | |
| 160 | + <th>Score</th> | |
| 161 | + </tr> | |
| 162 | + </thead> | |
| 163 | + <tbody> | |
| 164 | + (var dj in Snapshot.TopDjs) | |
| 165 | + { | |
| 166 | + <tr> | |
| 167 | + <td></td> | |
| 168 | + <td></td> | |
| 169 | + <td></td> | |
| 170 | + <td></td> | |
| 171 | + <td></td> | |
| 172 | + </tr> | |
| 173 | + } | |
| 174 | + </tbody> | |
| 175 | + </table> | |
| 176 | + </div> | |
| 177 | + </Panel> | |
| 178 | + | |
| 179 | + <Panel Legend="Top tracks"> | |
| 180 | + <div class="table-scroll"> | |
| 181 | + <table> | |
| 182 | + <thead> | |
| 183 | + <tr> | |
| 184 | + <th>Track</th> | |
| 185 | + <th>Artist</th> | |
| 186 | + <th>Plays</th> | |
| 187 | + <th>Score</th> | |
| 188 | + </tr> | |
| 189 | + </thead> | |
| 190 | + <tbody> | |
| 191 | + (var track in Snapshot.TopTracks) | |
| 192 | + { | |
| 193 | + <tr> | |
| 194 | + <td></td> | |
| 195 | + <td>(", ", track.ArtistNames)</td> | |
| 196 | + <td></td> | |
| 197 | + <td></td> | |
| 198 | + </tr> | |
| 199 | + } | |
| 200 | + </tbody> | |
| 201 | + </table> | |
| 202 | + </div> | |
| 203 | + </Panel> | |
| 204 | + | |
| 205 | + <Panel Legend="Top artists"> | |
| 206 | + <div class="table-scroll"> | |
| 207 | + <table> | |
| 208 | + <thead> | |
| 209 | + <tr> | |
| 210 | + <th>Artist</th> | |
| 211 | + <th>Plays</th> | |
| 212 | + <th>Score</th> | |
| 213 | + </tr> | |
| 214 | + </thead> | |
| 215 | + <tbody> | |
| 216 | + (var artist in Snapshot.TopArtists) | |
| 217 | + { | |
| 218 | + <tr> | |
| 219 | + <td></td> | |
| 220 | + <td></td> | |
| 221 | + <td></td> | |
| 222 | + </tr> | |
| 223 | + } | |
| 224 | + </tbody> | |
| 225 | + </table> | |
| 226 | + </div> | |
| 227 | + </Panel> | |
| 228 | + | |
| 229 | + <Panel Legend="Recently played"> | |
| 230 | + <div class="table-scroll"> | |
| 231 | + <table> | |
| 232 | + <thead> | |
| 233 | + <tr> | |
| 234 | + <th>When</th> | |
| 235 | + <th>DJ</th> | |
| 236 | + <th>Track</th> | |
| 237 | + <th>Artist</th> | |
| 238 | + <th>Score</th> | |
| 239 | + </tr> | |
| 240 | + </thead> | |
| 241 | + <tbody> | |
| 242 | + (var play in Snapshot.RecentPlays) | |
| 243 | + { | |
| 244 | + <tr> | |
| 245 | + <td><Timestamp At="play.PlayedAt"/></td> | |
| 246 | + <td></td> | |
| 247 | + <td></td> | |
| 248 | + <td>(", ", play.ArtistNames)</td> | |
| 249 | + <td></td> | |
| 250 | + </tr> | |
| 251 | + } | |
| 252 | + </tbody> | |
| 253 | + </table> | |
| 254 | + </div> | |
| 255 | + </Panel> | |
| 256 | + | |
| 257 | + <p class="muted"> | |
| 258 | + Read from <code></code> over Erlang distribution with | |
| 259 | + <a href="https://github.com/Besselking/BeamSharp">BeamSharp</a>: this site joins the | |
| 260 | + cluster as a hidden node and calls <code>Rvrb.Stats.snapshot/0</code> the way any other | |
| 261 | + BEAM node would. Snapshot taken <Timestamp At="Snapshot.GeneratedAt"/>. | |
| 262 | + </p> | |
| 263 | + } | |
| 264 | +</main> | |
Blog/Components/Pages/Rvrb.razor.cs +63 -0
| @@ -0,0 +1,63 @@ | ||
| 1 | +using Blog.Models; | |
| 2 | +using Blog.Services; | |
| 3 | +using Microsoft.AspNetCore.Components; | |
| 4 | +using Microsoft.Extensions.Options; | |
| 5 | + | |
| 6 | +namespace Blog.Components.Pages; | |
| 7 | + | |
| 8 | +public partial class Rvrb : ComponentBase | |
| 9 | +{ | |
| 10 | + | |
| 11 | + public required RvrbService Service { get; set; } | |
| 12 | + | |
| 13 | + | |
| 14 | + public required IOptions<RvrbOptions> Settings { get; set; } | |
| 15 | + | |
| 16 | + private RvrbStatus Status { get; set; } = RvrbStatus.Unreachable("not read yet"); | |
| 17 | + | |
| 18 | + private RvrbSnapshot? Snapshot => Status.Snapshot; | |
| 19 | + | |
| 20 | + private RvrbOptions Options => Settings.Value; | |
| 21 | + | |
| 22 | + /// <summary> | |
| 23 | + /// How stale the snapshot is by the time it reaches the browser. Snapshots are cached and | |
| 24 | + /// shared, so the track that was 40 seconds in when it was taken is further along by now — this | |
| 25 | + /// is what lets the page pick the counter up where the bot left it. | |
| 26 | + /// </summary> | |
| 27 | + private TimeSpan Age => DateTimeOffset.UtcNow - Status.FetchedAt; | |
| 28 | + | |
| 29 | + private string StatusLabel => Status switch | |
| 30 | + { | |
| 31 | + { Snapshot: null } => "offline", | |
| 32 | + { Snapshot.Live: null } => "up, not in the room", | |
| 33 | + _ => "up, in the room" | |
| 34 | + }; | |
| 35 | + | |
| 36 | + private string StatusClass => Status switch | |
| 37 | + { | |
| 38 | + { Snapshot: null } => "status-down", | |
| 39 | + { Snapshot.Live: null } => "status-idle", | |
| 40 | + _ => "status-up" | |
| 41 | + }; | |
| 42 | + | |
| 43 | + protected override async Task OnInitializedAsync() | |
| 44 | + { | |
| 45 | + Status = await Service.GetStatusAsync().ConfigureAwait(true); | |
| 46 | + await base.OnInitializedAsync().ConfigureAwait(true); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// <summary>The hover label on a bar: which day it is, and what it counts.</summary> | |
| 50 | + private static string BarTitle(RvrbDay day) => $"{day.Date:yyyy-MM-dd}: {day.Plays} plays"; | |
| 51 | + | |
| 52 | + /// <summary> | |
| 53 | + /// A day's bar height as a percentage of the busiest day in the window, floored at something | |
| 54 | + /// visible so a day with one play doesn't render as nothing at all. | |
| 55 | + /// </summary> | |
| 56 | + private int BarHeight(int plays) | |
| 57 | + { | |
| 58 | + if (plays == 0) return 0; | |
| 59 | + | |
| 60 | + var busiest = Snapshot?.PlaysPerDay.Max(day => day.Plays) ?? 0; | |
| 61 | + return busiest == 0 ? 0 : Math.Max(4, (int)Math.Round(plays * 100.0 / busiest)); | |
| 62 | + } | |
| 63 | +} | |
Blog/Components/Pages/Rvrb.razor.js +106 -0
| @@ -0,0 +1,106 @@ | ||
| 1 | +// The page itself is server-rendered and static. This adds the two things a status page wants that | |
| 2 | +// a snapshot can't carry: times that stay relative to now, and a track counter that keeps running | |
| 3 | +// between refreshes. | |
| 4 | + | |
| 5 | +const REFRESH_KEY = "rvrb-auto-refresh"; | |
| 6 | +const REFRESH_MS = 30_000; | |
| 7 | + | |
| 8 | +let timer; | |
| 9 | + | |
| 10 | +export function onLoad() { | |
| 11 | + wireAutoRefresh(); | |
| 12 | + startTicking(); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function onUpdate() { | |
| 16 | + // Enhanced navigation swaps the DOM without a page load, so whatever was ticking is now | |
| 17 | + // pointing at elements that are gone. | |
| 18 | + stopTicking(); | |
| 19 | + wireAutoRefresh(); | |
| 20 | + startTicking(); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function onDispose() { | |
| 24 | + stopTicking(); | |
| 25 | +} | |
| 26 | + | |
| 27 | +function wireAutoRefresh() { | |
| 28 | + const checkbox = document.getElementById("autoRefresh"); | |
| 29 | + if (!checkbox) return; | |
| 30 | + | |
| 31 | + // Read back from storage, since the setting has to survive the very reload it causes. | |
| 32 | + checkbox.checked = localStorage.getItem(REFRESH_KEY) === "on"; | |
| 33 | + checkbox.addEventListener("change", () => { | |
| 34 | + localStorage.setItem(REFRESH_KEY, checkbox.checked ? "on" : "off"); | |
| 35 | + }); | |
| 36 | +} | |
| 37 | + | |
| 38 | +function startTicking() { | |
| 39 | + const started = performance.now(); | |
| 40 | + const progress = document.getElementById("trackProgress"); | |
| 41 | + const elapsedLabel = document.getElementById("trackElapsed"); | |
| 42 | + const times = document.querySelectorAll("time[data-relative]"); | |
| 43 | + const checkbox = document.getElementById("autoRefresh"); | |
| 44 | + | |
| 45 | + const tick = () => { | |
| 46 | + const sinceRender = performance.now() - started; | |
| 47 | + | |
| 48 | + if (progress) advanceTrack(progress, elapsedLabel, sinceRender); | |
| 49 | + times.forEach(relabel); | |
| 50 | + | |
| 51 | + if (checkbox?.checked && sinceRender >= REFRESH_MS) location.reload(); | |
| 52 | + }; | |
| 53 | + | |
| 54 | + tick(); | |
| 55 | + timer = setInterval(tick, 1000); | |
| 56 | +} | |
| 57 | + | |
| 58 | +function stopTicking() { | |
| 59 | + clearInterval(timer); | |
| 60 | + timer = undefined; | |
| 61 | +} | |
| 62 | + | |
| 63 | +// The snapshot says how far into the track the bot was when it was taken, and how long ago that | |
| 64 | +// was; everything after that is just wall clock. | |
| 65 | +function advanceTrack(progress, label, sinceRender) { | |
| 66 | + const duration = Number(progress.dataset.durationMs); | |
| 67 | + if (!duration) return; | |
| 68 | + | |
| 69 | + const elapsed = Math.min( | |
| 70 | + Number(progress.dataset.elapsedMs) + Number(progress.dataset.ageMs) + sinceRender, | |
| 71 | + duration); | |
| 72 | + | |
| 73 | + progress.value = Math.round((elapsed / duration) * Number(progress.max)); | |
| 74 | + if (label) label.textContent = formatDuration(elapsed); | |
| 75 | +} | |
| 76 | + | |
| 77 | +function relabel(time) { | |
| 78 | + const at = Date.parse(time.dateTime); | |
| 79 | + if (Number.isNaN(at)) return; | |
| 80 | + | |
| 81 | + time.title ||= time.textContent; | |
| 82 | + time.textContent = formatAgo(Date.now() - at); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function formatAgo(ms) { | |
| 86 | + if (ms < 0) return "just now"; | |
| 87 | + | |
| 88 | + const seconds = Math.floor(ms / 1000); | |
| 89 | + if (seconds < 60) return `${seconds}s ago`; | |
| 90 | + | |
| 91 | + const minutes = Math.floor(seconds / 60); | |
| 92 | + if (minutes < 60) return `${minutes}m ago`; | |
| 93 | + | |
| 94 | + const hours = Math.floor(minutes / 60); | |
| 95 | + if (hours < 24) return `${hours}h ago`; | |
| 96 | + | |
| 97 | + const days = Math.floor(hours / 24); | |
| 98 | + return days < 30 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`; | |
| 99 | +} | |
| 100 | + | |
| 101 | +function formatDuration(ms) { | |
| 102 | + const seconds = Math.max(Math.floor(ms / 1000), 0); | |
| 103 | + const minutes = Math.floor(seconds / 60); | |
| 104 | + | |
| 105 | + return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`; | |
| 106 | +} | |
Blog/Components/_Shared/Timestamp.razor +12 -0
| @@ -0,0 +1,12 @@ | ||
| 1 | +@* An absolute time that a page script may rewrite as a relative one ("3m ago"). *@ | |
| 2 | +@* The rendered text is the absolute time, so it still says something useful without script. *@ | |
| 3 | + | |
| 4 | +<time datetime="@At.ToUniversalTime().ToString("O")" data-relative></time> | |
| 5 | + | |
| 6 | + { | |
| 7 | + [Parameter] | |
| 8 | + [EditorRequired] | |
| 9 | + public DateTimeOffset At { get; set; } | |
| 10 | + | |
| 11 | + private string Absolute => At.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'"); | |
| 12 | +} | |
Blog/Format.cs +26 -0
| @@ -0,0 +1,26 @@ | ||
| 1 | +namespace Blog; | |
| 2 | + | |
| 3 | +/// <summary> | |
| 4 | +/// Short human renderings of numbers that would otherwise be printed raw. | |
| 5 | +/// </summary> | |
| 6 | +public static class Format | |
| 7 | +{ | |
| 8 | + /// <summary> | |
| 9 | + /// A duration as the largest two units that carry information — "3d 4h", "1h 4m", "3m 20s", | |
| 10 | + /// "42s" — matching what the bot itself prints in chat. Null renders as "?", since a duration | |
| 11 | + /// we don't know is not the same as one of zero. | |
| 12 | + /// </summary> | |
| 13 | + public static string Duration(TimeSpan? span) | |
| 14 | + { | |
| 15 | + if (span is not { } value) return "?"; | |
| 16 | + if (value < TimeSpan.Zero) value = TimeSpan.Zero; | |
| 17 | + | |
| 18 | + return value switch | |
| 19 | + { | |
| 20 | + { TotalDays: >= 1 } => $"{(int)value.TotalDays}d {value.Hours}h", | |
| 21 | + { TotalHours: >= 1 } => $"{(int)value.TotalHours}h {value.Minutes}m", | |
| 22 | + { TotalMinutes: >= 1 } => $"{(int)value.TotalMinutes}m {value.Seconds}s", | |
| 23 | + _ => $"{(int)value.TotalSeconds}s" | |
| 24 | + }; | |
| 25 | + } | |
| 26 | +} | |
Blog/Models/RvrbSnapshot.cs +121 -0
| @@ -0,0 +1,121 @@ | ||
| 1 | +using System.ComponentModel; | |
| 2 | + | |
| 3 | +namespace Blog.Models; | |
| 4 | + | |
| 5 | +/// <summary> | |
| 6 | +/// What <c>Rvrb.Stats.snapshot/0</c> answers with: the rvrb bot's room as it is right now, and | |
| 7 | +/// what it has played since the plays table started filling up. Decoded from the Erlang term in | |
| 8 | +/// <see cref="Services.RvrbSnapshotReader"/>. | |
| 9 | +/// </summary> | |
| 10 | +public sealed record RvrbSnapshot( | |
| 11 | + DateTimeOffset GeneratedAt, | |
| 12 | + RvrbLive? Live, | |
| 13 | + RvrbTotals Totals, | |
| 14 | + IReadOnlyList<RvrbDay> PlaysPerDay, | |
| 15 | + IReadOnlyList<RvrbDj> TopDjs, | |
| 16 | + IReadOnlyList<RvrbTrack> TopTracks, | |
| 17 | + IReadOnlyList<RvrbArtist> TopArtists, | |
| 18 | + IReadOnlyList<RvrbPlay> RecentPlays); | |
| 19 | + | |
| 20 | +/// <summary> | |
| 21 | +/// The room as the bot's websocket currently sees it. Null in a snapshot means the bot is not | |
| 22 | +/// connected to RVRB — the historic half is still there, since it only needs the database. | |
| 23 | +/// </summary> | |
| 24 | +public sealed record RvrbLive( | |
| 25 | + string? ChannelId, | |
| 26 | + RvrbCurrentTrack? CurrentTrack, | |
| 27 | + int Dopes, | |
| 28 | + int Stars, | |
| 29 | + bool AutoDoped, | |
| 30 | + bool AutoStarred, | |
| 31 | + int QueuedTracks, | |
| 32 | + int KnownBots, | |
| 33 | + IReadOnlyList<RvrbDjSlot> Djs, | |
| 34 | + TimeSpan Lap); | |
| 35 | + | |
| 36 | +/// <summary>The track playing right now.</summary> | |
| 37 | +public sealed record RvrbCurrentTrack( | |
| 38 | + string? SpotifyTrackId, | |
| 39 | + string? Name, | |
| 40 | + IReadOnlyList<string> ArtistNames, | |
| 41 | + TimeSpan? Duration, | |
| 42 | + string? AlbumArt, | |
| 43 | + TimeSpan? Elapsed, | |
| 44 | + TimeSpan? Remaining) | |
| 45 | +{ | |
| 46 | + /// <summary> | |
| 47 | + /// How far through the track we are, 0 to 1, or null when either half of that is unknown — | |
| 48 | + /// a track that arrived without a duration, or one already playing when the bot connected. | |
| 49 | + /// </summary> | |
| 50 | + public double? Progress => Duration is { TotalMilliseconds: > 0 } total && Elapsed is { } elapsed | |
| 51 | + ? Math.Clamp(elapsed.TotalMilliseconds / total.TotalMilliseconds, 0, 1) | |
| 52 | + : null; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// <summary>One slot in the DJ queue, with the wait until that DJ next plays.</summary> | |
| 56 | +public sealed record RvrbDjSlot( | |
| 57 | + string RvrbId, | |
| 58 | + string? Name, | |
| 59 | + TimeSpan AverageTrack, | |
| 60 | + int PlayCount, | |
| 61 | + bool Measured, | |
| 62 | + bool Current, | |
| 63 | + TimeSpan Wait); | |
| 64 | + | |
| 65 | +/// <summary>Room-wide counts.</summary> | |
| 66 | +public sealed record RvrbTotals( | |
| 67 | + long Plays, | |
| 68 | + long Votes, | |
| 69 | + long Dopes, | |
| 70 | + long Stars, | |
| 71 | + long Users, | |
| 72 | + long Djs, | |
| 73 | + long Tracks, | |
| 74 | + long Artists, | |
| 75 | + DateTimeOffset? FirstPlayAt, | |
| 76 | + DateTimeOffset? LastPlayAt); | |
| 77 | + | |
| 78 | +/// <summary>Plays on one day. Days nothing was played on are present, with a count of zero.</summary> | |
| 79 | +public sealed record RvrbDay(DateOnly Date, int Plays); | |
| 80 | + | |
| 81 | +/// <summary>A DJ on the leaderboard, and what their plays earned.</summary> | |
| 82 | +public sealed record RvrbDj(string Name, string? UserName, int Plays, int Dopes, int Stars, int Score); | |
| 83 | + | |
| 84 | +/// <summary>A track on the leaderboard.</summary> | |
| 85 | +public sealed record RvrbTrack( | |
| 86 | + string TrackName, | |
| 87 | + IReadOnlyList<string> ArtistNames, | |
| 88 | + int Plays, | |
| 89 | + int Dopes, | |
| 90 | + int Stars, | |
| 91 | + int Score); | |
| 92 | + | |
| 93 | +/// <summary>An artist on the leaderboard.</summary> | |
| 94 | +public sealed record RvrbArtist(string ArtistName, int Plays, int Dopes, int Stars, int Score); | |
| 95 | + | |
| 96 | +/// <summary>One played track in the recent history.</summary> | |
| 97 | +public sealed record RvrbPlay( | |
| 98 | + DateTimeOffset PlayedAt, | |
| 99 | + string? Dj, | |
| 100 | + string TrackName, | |
| 101 | + IReadOnlyList<string> ArtistNames, | |
| 102 | + TimeSpan? Duration, | |
| 103 | + int Dopes, | |
| 104 | + int Stars, | |
| 105 | + int Score); | |
| 106 | + | |
| 107 | +/// <summary> | |
| 108 | +/// The result of asking the bot for a snapshot: either one, or why there isn't one. A bot that is | |
| 109 | +/// down is the normal thing a status page is there to report, so it is a value here rather than an | |
| 110 | +/// exception the page has to catch. | |
| 111 | +/// </summary> | |
| 112 | +// Nothing here is ever mutated after it is built, and saying so is what lets HybridCache hand the | |
| 113 | +// same instance to every request instead of serializing a copy per reader. | |
| 114 | + | |
| 115 | +public sealed record RvrbStatus(DateTimeOffset FetchedAt, RvrbSnapshot? Snapshot, string? Error) | |
| 116 | +{ | |
| 117 | + public static RvrbStatus Reachable(RvrbSnapshot snapshot) => | |
| 118 | + new(DateTimeOffset.UtcNow, snapshot, null); | |
| 119 | + | |
| 120 | + public static RvrbStatus Unreachable(string error) => new(DateTimeOffset.UtcNow, null, error); | |
| 121 | +} | |
Blog/Program.cs +5 -0
| @@ -15,6 +15,11 @@ builder.Services.AddHttpClient<BrpService>( | ||
| 15 | 15 | client.BaseAddress = new Uri("https://brp.bes.is/"); |
| 16 | 16 | }); |
| 17 | 17 | |
| 18 | +// /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this | |
| 19 | +// site dials with is started on the first request, not here - see RvrbService. | |
| 20 | +builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section)); | |
| 21 | +builder.Services.AddSingleton<RvrbService>(); | |
| 22 | + | |
| 18 | 23 | // builder.Services.AddTransient<BrpService>(); |
| 19 | 24 | // builder.Services.AddTransient<NsService>(); |
| 20 | 25 | |
Blog/Services/RvrbService.cs +154 -0
| @@ -0,0 +1,154 @@ | ||
| 1 | +using System.Net.Sockets; | |
| 2 | +using BeamSharp.Node; | |
| 3 | +using Blog.Models; | |
| 4 | +using Microsoft.Extensions.Caching.Hybrid; | |
| 5 | +using Microsoft.Extensions.Options; | |
| 6 | + | |
| 7 | +namespace Blog.Services; | |
| 8 | + | |
| 9 | +/// <summary>Where the rvrb bot is, and how patient to be with it.</summary> | |
| 10 | +public sealed class RvrbOptions | |
| 11 | +{ | |
| 12 | + public const string Section = "Rvrb"; | |
| 13 | + | |
| 14 | + /// <summary>The bot's node, as <c>name@host</c>. It runs on this machine, hence loopback.</summary> | |
| 15 | + public string Node { get; set; } = "rvrb@127.0.0.1"; | |
| 16 | + | |
| 17 | + /// <summary>The name this site registers with EPMD under.</summary> | |
| 18 | + public string LocalNode { get; set; } = "blog@127.0.0.1"; | |
| 19 | + | |
| 20 | + /// <summary> | |
| 21 | + /// The Erlang cookie both nodes share. Not in appsettings.json — it is the only thing standing | |
| 22 | + /// between a local process and the bot's node, so it comes from user secrets in development and | |
| 23 | + /// from the environment (<c>Rvrb__Cookie</c>) in production. | |
| 24 | + /// </summary> | |
| 25 | + public string? Cookie { get; set; } | |
| 26 | + | |
| 27 | + /// <summary>How long to wait for the bot to answer before calling it unreachable.</summary> | |
| 28 | + public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5); | |
| 29 | + | |
| 30 | + /// <summary> | |
| 31 | + /// How long a snapshot is served to everyone who asks. The live half moves in seconds, so this | |
| 32 | + /// is short — but it is what keeps a refresh loop, or a crawler, from turning into one | |
| 33 | + /// round trip and a handful of queries per request. | |
| 34 | + /// </summary> | |
| 35 | + public TimeSpan CacheFor { get; set; } = TimeSpan.FromSeconds(15); | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// <summary> | |
| 39 | +/// Reads the rvrb bot's stats over Erlang distribution. | |
| 40 | +/// </summary> | |
| 41 | +/// <remarks> | |
| 42 | +/// The site joins the cluster as a hidden Erlang node of its own — BeamSharp speaks the | |
| 43 | +/// distribution protocol, so from the bot's side this is an ordinary <c>:rpc.call/4</c> arriving | |
| 44 | +/// from a peer, and nothing on the Elixir side had to grow an HTTP endpoint for it. | |
| 45 | +/// | |
| 46 | +/// The node is started on the first request rather than at boot, deliberately: registering with | |
| 47 | +/// EPMD can fail (it isn't running, the bot was deployed without distribution), and the rest of | |
| 48 | +/// this site has no business failing to start over a page that shows a bot's play count. A failure | |
| 49 | +/// here is a value, not an exception — see <see cref="RvrbStatus"/>. | |
| 50 | +/// </remarks> | |
| 51 | +public sealed class RvrbService( | |
| 52 | + HybridCache cache, | |
| 53 | + IOptions<RvrbOptions> options, | |
| 54 | + ILogger<RvrbService> logger) : IAsyncDisposable | |
| 55 | +{ | |
| 56 | + private readonly RvrbOptions _options = options.Value; | |
| 57 | + private readonly SemaphoreSlim _startGate = new(1, 1); | |
| 58 | + private ErlangNode? _node; | |
| 59 | + | |
| 60 | + public async ValueTask<RvrbStatus> GetStatusAsync(CancellationToken cancellationToken = default) | |
| 61 | + { | |
| 62 | + return await cache.GetOrCreateAsync( | |
| 63 | + "rvrb/snapshot", | |
| 64 | + this, | |
| 65 | + static (service, cancel) => service.FetchAsync(cancel), | |
| 66 | + new HybridCacheEntryOptions | |
| 67 | + { | |
| 68 | + Expiration = _options.CacheFor, | |
| 69 | + LocalCacheExpiration = _options.CacheFor | |
| 70 | + }, | |
| 71 | + cancellationToken: cancellationToken) | |
| 72 | + .ConfigureAwait(false); | |
| 73 | + } | |
| 74 | + | |
| 75 | + private async ValueTask<RvrbStatus> FetchAsync(CancellationToken cancellationToken) | |
| 76 | + { | |
| 77 | + try | |
| 78 | + { | |
| 79 | + var node = await StartedNodeAsync(cancellationToken).ConfigureAwait(false); | |
| 80 | + | |
| 81 | + // Elixir modules are atoms prefixed with `Elixir.`, which is what `Rvrb.Stats` is on | |
| 82 | + // the wire. `snapshot/0` exists because its options argument has a default. | |
| 83 | + var reply = await node | |
| 84 | + .RpcAsync(_options.Node, "Elixir.Rvrb.Stats", "snapshot", [], _options.CallTimeout, | |
| 85 | + cancellationToken) | |
| 86 | + .ConfigureAwait(false); | |
| 87 | + | |
| 88 | + return RvrbStatus.Reachable(RvrbSnapshotReader.Read(reply)); | |
| 89 | + } | |
| 90 | + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException | |
| 91 | + or ErlangRpcException or ErlangExitException | |
| 92 | + or InvalidOperationException or FormatException) | |
| 93 | + { | |
| 94 | + logger.LogWarning(ex, "could not read stats from {Node}", _options.Node); | |
| 95 | + return RvrbStatus.Unreachable(Describe(ex)); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + /// <summary> | |
| 100 | + /// This site's own node, started on first use. A start that fails leaves nothing behind, so the | |
| 101 | + /// next request tries again — which is what recovers the page once the bot comes back. | |
| 102 | + /// </summary> | |
| 103 | + private async ValueTask<ErlangNode> StartedNodeAsync(CancellationToken cancellationToken) | |
| 104 | + { | |
| 105 | + if (_node is { } running) return running; | |
| 106 | + | |
| 107 | + await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false); | |
| 108 | + try | |
| 109 | + { | |
| 110 | + if (_node is { } started) return started; | |
| 111 | + | |
| 112 | + var node = new ErlangNode(_options.LocalNode, new ErlangNodeOptions | |
| 113 | + { | |
| 114 | + Cookie = _options.Cookie, | |
| 115 | + // Both nodes are on this machine, so neither the listener nor the lookup has any | |
| 116 | + // business leaving it. Erlang distribution authenticates with a shared cookie and | |
| 117 | + // then sends everything in the clear. | |
| 118 | + BindAddress = "127.0.0.1", | |
| 119 | + EpmdHost = "127.0.0.1", | |
| 120 | + Log = line => logger.LogDebug("beamsharp: {Message}", line) | |
| 121 | + }); | |
| 122 | + | |
| 123 | + try | |
| 124 | + { | |
| 125 | + await node.StartAsync(cancellationToken).ConfigureAwait(false); | |
| 126 | + } | |
| 127 | + catch | |
| 128 | + { | |
| 129 | + // StartAsync binds the listener before it registers with EPMD, so a failure after | |
| 130 | + // that point would otherwise leak a socket per attempt. | |
| 131 | + await node.DisposeAsync().ConfigureAwait(false); | |
| 132 | + throw; | |
| 133 | + } | |
| 134 | + | |
| 135 | + _node = node; | |
| 136 | + return node; | |
| 137 | + } | |
| 138 | + finally | |
| 139 | + { | |
| 140 | + _startGate.Release(); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + // The useful half of these is usually the inner exception: BeamSharp reports an unknown node, | |
| 145 | + // a bad cookie and a refused connection as one IOException carrying the reason underneath. | |
| 146 | + private static string Describe(Exception ex) => | |
| 147 | + ex.InnerException is { } inner ? $"{ex.Message}: {inner.Message}" : ex.Message; | |
| 148 | + | |
| 149 | + public async ValueTask DisposeAsync() | |
| 150 | + { | |
| 151 | + if (_node is { } node) await node.DisposeAsync().ConfigureAwait(false); | |
| 152 | + _startGate.Dispose(); | |
| 153 | + } | |
| 154 | +} | |
Blog/Services/RvrbSnapshotReader.cs +155 -0
| @@ -0,0 +1,155 @@ | ||
| 1 | +using System.Globalization; | |
| 2 | +using BeamSharp.Terms; | |
| 3 | +using Blog.Models; | |
| 4 | + | |
| 5 | +namespace Blog.Services; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// Turns the term <c>Rvrb.Stats.snapshot/0</c> answers with into the records the page renders. | |
| 9 | +/// </summary> | |
| 10 | +/// <remarks> | |
| 11 | +/// Written out by hand rather than through <c>BeamSharp.Serialization</c>, because the shape on | |
| 12 | +/// the wire is an Elixir map written by Elixir — snake_case atom keys, binaries for strings, | |
| 13 | +/// <c>nil</c> for absent values — and not a C# type that happens to be serialized. Reading it is | |
| 14 | +/// deliberately forgiving: a key this side does not know about is ignored, and a missing one takes | |
| 15 | +/// its default, so the bot can grow a field without the site having to be redeployed first. | |
| 16 | +/// </remarks> | |
| 17 | +internal static class RvrbSnapshotReader | |
| 18 | +{ | |
| 19 | + public static RvrbSnapshot Read(ErlTerm term) | |
| 20 | + { | |
| 21 | + var root = AsMap(term) ?? throw new FormatException($"expected a map, got {term}"); | |
| 22 | + | |
| 23 | + return new RvrbSnapshot( | |
| 24 | + GeneratedAt: Time(root.Get("generated_at")) ?? DateTimeOffset.UtcNow, | |
| 25 | + Live: ReadLive(AsMap(root.Get("live"))), | |
| 26 | + Totals: ReadTotals(AsMap(root.Get("totals"))), | |
| 27 | + PlaysPerDay: ReadList(root.Get("plays_per_day"), ReadDay), | |
| 28 | + TopDjs: ReadList(root.Get("top_djs"), ReadDj), | |
| 29 | + TopTracks: ReadList(root.Get("top_tracks"), ReadTrack), | |
| 30 | + TopArtists: ReadList(root.Get("top_artists"), ReadArtist), | |
| 31 | + RecentPlays: ReadList(root.Get("recent_plays"), ReadPlay)); | |
| 32 | + } | |
| 33 | + | |
| 34 | + private static RvrbLive? ReadLive(ErlMap? live) => live is null | |
| 35 | + ? null | |
| 36 | + : new RvrbLive( | |
| 37 | + ChannelId: Text(live.Get("channel_id")), | |
| 38 | + CurrentTrack: ReadCurrentTrack(AsMap(live.Get("current_track"))), | |
| 39 | + Dopes: (int)Int(live.Get("dopes")), | |
| 40 | + Stars: (int)Int(live.Get("stars")), | |
| 41 | + AutoDoped: Bool(live.Get("auto_doped")), | |
| 42 | + AutoStarred: Bool(live.Get("auto_starred")), | |
| 43 | + QueuedTracks: (int)Int(live.Get("queued_tracks")), | |
| 44 | + KnownBots: (int)Int(live.Get("known_bots")), | |
| 45 | + Djs: ReadList(live.Get("djs"), ReadDjSlot), | |
| 46 | + Lap: Duration(live.Get("lap_ms")) ?? TimeSpan.Zero); | |
| 47 | + | |
| 48 | + private static RvrbCurrentTrack? ReadCurrentTrack(ErlMap? track) => track is null | |
| 49 | + ? null | |
| 50 | + : new RvrbCurrentTrack( | |
| 51 | + SpotifyTrackId: Text(track.Get("spotify_track_id")), | |
| 52 | + Name: Text(track.Get("name")), | |
| 53 | + ArtistNames: ReadStrings(track.Get("artist_names")), | |
| 54 | + Duration: Duration(track.Get("duration_ms")), | |
| 55 | + AlbumArt: Text(track.Get("album_art")), | |
| 56 | + Elapsed: Duration(track.Get("elapsed_ms")), | |
| 57 | + Remaining: Duration(track.Get("remaining_ms"))); | |
| 58 | + | |
| 59 | + private static RvrbDjSlot ReadDjSlot(ErlMap dj) => new( | |
| 60 | + RvrbId: Text(dj.Get("rvrb_id")) ?? "", | |
| 61 | + Name: Text(dj.Get("name")), | |
| 62 | + AverageTrack: Duration(dj.Get("avg_track_ms")) ?? TimeSpan.Zero, | |
| 63 | + PlayCount: (int)Int(dj.Get("play_count")), | |
| 64 | + Measured: Bool(dj.Get("measured")), | |
| 65 | + Current: Bool(dj.Get("current")), | |
| 66 | + Wait: Duration(dj.Get("wait_ms")) ?? TimeSpan.Zero); | |
| 67 | + | |
| 68 | + private static RvrbTotals ReadTotals(ErlMap? totals) => new( | |
| 69 | + Plays: Int(totals?.Get("plays")), | |
| 70 | + Votes: Int(totals?.Get("votes")), | |
| 71 | + Dopes: Int(totals?.Get("dopes")), | |
| 72 | + Stars: Int(totals?.Get("stars")), | |
| 73 | + Users: Int(totals?.Get("users")), | |
| 74 | + Djs: Int(totals?.Get("djs")), | |
| 75 | + Tracks: Int(totals?.Get("tracks")), | |
| 76 | + Artists: Int(totals?.Get("artists")), | |
| 77 | + FirstPlayAt: Time(totals?.Get("first_play_at")), | |
| 78 | + LastPlayAt: Time(totals?.Get("last_play_at"))); | |
| 79 | + | |
| 80 | + private static RvrbDay ReadDay(ErlMap day) => new( | |
| 81 | + Date: Text(day.Get("date")) is { } date | |
| 82 | + ? DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture) | |
| 83 | + : default, | |
| 84 | + Plays: (int)Int(day.Get("plays"))); | |
| 85 | + | |
| 86 | + private static RvrbDj ReadDj(ErlMap dj) => new( | |
| 87 | + Name: Text(dj.Get("name")) ?? "someone", | |
| 88 | + UserName: Text(dj.Get("user_name")), | |
| 89 | + Plays: (int)Int(dj.Get("plays")), | |
| 90 | + Dopes: (int)Int(dj.Get("dopes")), | |
| 91 | + Stars: (int)Int(dj.Get("stars")), | |
| 92 | + Score: (int)Int(dj.Get("score"))); | |
| 93 | + | |
| 94 | + private static RvrbTrack ReadTrack(ErlMap track) => new( | |
| 95 | + TrackName: Text(track.Get("track_name")) ?? "unknown track", | |
| 96 | + ArtistNames: ReadStrings(track.Get("artist_names")), | |
| 97 | + Plays: (int)Int(track.Get("plays")), | |
| 98 | + Dopes: (int)Int(track.Get("dopes")), | |
| 99 | + Stars: (int)Int(track.Get("stars")), | |
| 100 | + Score: (int)Int(track.Get("score"))); | |
| 101 | + | |
| 102 | + private static RvrbArtist ReadArtist(ErlMap artist) => new( | |
| 103 | + ArtistName: Text(artist.Get("artist_name")) ?? "unknown artist", | |
| 104 | + Plays: (int)Int(artist.Get("plays")), | |
| 105 | + Dopes: (int)Int(artist.Get("dopes")), | |
| 106 | + Stars: (int)Int(artist.Get("stars")), | |
| 107 | + Score: (int)Int(artist.Get("score"))); | |
| 108 | + | |
| 109 | + private static RvrbPlay ReadPlay(ErlMap play) => new( | |
| 110 | + PlayedAt: Time(play.Get("played_at")) ?? default, | |
| 111 | + Dj: Text(play.Get("dj")), | |
| 112 | + TrackName: Text(play.Get("track_name")) ?? "unknown track", | |
| 113 | + ArtistNames: ReadStrings(play.Get("artist_names")), | |
| 114 | + Duration: Duration(play.Get("duration_ms")), | |
| 115 | + Dopes: (int)Int(play.Get("dopes")), | |
| 116 | + Stars: (int)Int(play.Get("stars")), | |
| 117 | + Score: (int)Int(play.Get("score"))); | |
| 118 | + | |
| 119 | + // ------------------------------------------------------------------ terms | |
| 120 | + | |
| 121 | + // `nil` is a real value on the Elixir side rather than an absent key, so it has to read as | |
| 122 | + // "no map" here — otherwise a disconnected bot would decode as a live one with everything zero. | |
| 123 | + private static ErlMap? AsMap(ErlTerm? term) => term as ErlMap; | |
| 124 | + | |
| 125 | + private static string? Text(ErlTerm? term) => term switch | |
| 126 | + { | |
| 127 | + ErlBinary binary => binary.AsString(), | |
| 128 | + ErlAtom { Name: "nil" or "undefined" } => null, | |
| 129 | + ErlAtom atom => atom.Name, | |
| 130 | + _ => null | |
| 131 | + }; | |
| 132 | + | |
| 133 | + private static long Int(ErlTerm? term) => term is ErlInt value ? (long)value.Value : 0; | |
| 134 | + | |
| 135 | + private static bool Bool(ErlTerm? term) => term is ErlAtom { Name: "true" }; | |
| 136 | + | |
| 137 | + private static TimeSpan? Duration(ErlTerm? term) => | |
| 138 | + term is ErlInt ms ? TimeSpan.FromMilliseconds((double)ms.Value) : null; | |
| 139 | + | |
| 140 | + private static DateTimeOffset? Time(ErlTerm? term) => | |
| 141 | + Text(term) is { } text && | |
| 142 | + DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at) | |
| 143 | + ? at.ToUniversalTime() | |
| 144 | + : null; | |
| 145 | + | |
| 146 | + private static IReadOnlyList<string> ReadStrings(ErlTerm? term) => | |
| 147 | + term is ErlList list | |
| 148 | + ? list.ToArray().Select(item => Text(item) ?? "").ToArray() | |
| 149 | + : []; | |
| 150 | + | |
| 151 | + private static IReadOnlyList<T> ReadList<T>(ErlTerm? term, Func<ErlMap, T> read) => | |
| 152 | + term is ErlList list | |
| 153 | + ? list.ToArray().OfType<ErlMap>().Select(read).ToArray() | |
| 154 | + : []; | |
| 155 | +} | |
Blog/appsettings.json +5 -1
| @@ -5,5 +5,9 @@ | ||
| 5 | 5 | : "Warning" |
| 6 | 6 | } |
| 7 | 7 | }, |
| 8 | - : "*" | |
| 8 | + : "*", | |
| 9 | + : { | |
| 10 | + : "rvrb@127.0.0.1", | |
| 11 | + : "blog@127.0.0.1" | |
| 12 | + } | |
| 9 | 13 | } |
Blog/wwwroot/app.css +119 -0
| @@ -527,6 +527,125 @@ dialog > .panel > legend { | ||
| 527 | 527 | border-radius: var(); |
| 528 | 528 | } |
| 529 | 529 | |
| 530 | +/* -- Tables --------------------------------------------------------------- */ | |
| 531 | + | |
| 532 | +table { | |
| 533 | + inline-size: 100%; | |
| 534 | + border-collapse: collapse; | |
| 535 | +} | |
| 536 | + | |
| 537 | +:is(th, td) { | |
| 538 | + text-align: start; | |
| 539 | + padding: var() var(); | |
| 540 | + padding-inline-start: 0; | |
| 541 | + border-block-end: var() solid var(); | |
| 542 | + vertical-align: baseline; | |
| 543 | +} | |
| 544 | + | |
| 545 | +tbody tr:last-child :is(th, td) { | |
| 546 | + border-block-end: none; | |
| 547 | +} | |
| 548 | + | |
| 549 | +/* A table with more columns than the page has room for scrolls inside its own | |
| 550 | + box rather than pushing the whole page sideways. */ | |
| 551 | +.table-scroll { | |
| 552 | + overflow-x: auto; | |
| 553 | +} | |
| 554 | + | |
| 555 | + | |
| 556 | +/* -- rvrb stats ----------------------------------------------------------- */ | |
| 557 | + | |
| 558 | +.status-up { | |
| 559 | + color: var(); | |
| 560 | +} | |
| 561 | + | |
| 562 | +.status-idle { | |
| 563 | + color: var(); | |
| 564 | +} | |
| 565 | + | |
| 566 | +.status-down { | |
| 567 | + color: var(); | |
| 568 | +} | |
| 569 | + | |
| 570 | +.muted { | |
| 571 | + opacity: 50%; | |
| 572 | +} | |
| 573 | + | |
| 574 | +.now-playing { | |
| 575 | + display: flex; | |
| 576 | + flex-wrap: wrap; | |
| 577 | + gap: var(); | |
| 578 | + align-items: start; | |
| 579 | +} | |
| 580 | + | |
| 581 | +/* Overrides the block margin and auto-centring the base `img` rule gives every | |
| 582 | + image, which would otherwise push the art away from the track it belongs to. */ | |
| 583 | +.now-playing .album-art { | |
| 584 | + margin: 0; | |
| 585 | + flex: none; | |
| 586 | + inline-size: 8em; | |
| 587 | + block-size: 8em; | |
| 588 | + border-radius: var(); | |
| 589 | +} | |
| 590 | + | |
| 591 | +.now-playing > div { | |
| 592 | + flex: 1 1 20ch; | |
| 593 | +} | |
| 594 | + | |
| 595 | +progress { | |
| 596 | + inline-size: 100%; | |
| 597 | + block-size: var(); | |
| 598 | +} | |
| 599 | + | |
| 600 | +/* Counts big enough to read at a glance, laid out as many per row as fit. */ | |
| 601 | +.stats { | |
| 602 | + display: grid; | |
| 603 | + grid-template-columns: repeat(auto-fit, minmax(10ch, 1fr)); | |
| 604 | + gap: var(); | |
| 605 | + margin-block: 0; | |
| 606 | +} | |
| 607 | + | |
| 608 | +.stats dt { | |
| 609 | + opacity: 60%; | |
| 610 | +} | |
| 611 | + | |
| 612 | +.stats dd { | |
| 613 | + margin-inline-start: 0; | |
| 614 | + font-size: calc(1em * var()); | |
| 615 | +} | |
| 616 | + | |
| 617 | +/* Plays per day. One <li> per day, so a quiet day is an empty column rather | |
| 618 | + than a gap - see Rvrb.Stats.plays_per_day/1. */ | |
| 619 | +.bars { | |
| 620 | + display: flex; | |
| 621 | + align-items: end; | |
| 622 | + gap: var(); | |
| 623 | + block-size: 6em; | |
| 624 | + margin-block: 0; | |
| 625 | + padding-inline: 0; | |
| 626 | + list-style: none; | |
| 627 | +} | |
| 628 | + | |
| 629 | +.bars li { | |
| 630 | + display: flex; | |
| 631 | + flex: 1; | |
| 632 | + flex-direction: column; | |
| 633 | + align-items: center; | |
| 634 | + justify-content: end; | |
| 635 | + block-size: 100%; | |
| 636 | +} | |
| 637 | + | |
| 638 | +.bars .bar { | |
| 639 | + inline-size: 100%; | |
| 640 | + background-color: var(); | |
| 641 | +} | |
| 642 | + | |
| 643 | +.bars .bar-label { | |
| 644 | + font-size: 0.7em; | |
| 645 | + opacity: 60%; | |
| 646 | +} | |
| 647 | + | |
| 648 | + | |
| 530 | 649 | /* -- Storage ------------------------------------------------------------- */ |
| 531 | 650 | |
| 532 | 651 | .grid-stores { |
CLAUDE.md +20 -0
| @@ -63,6 +63,26 @@ are third-party or semi-vendored libraries used by specific pages (e.g. `Query.r | ||
| 63 | 63 | uses both `dactal.js` and `jsonql-js` to run two query languages against the same mock |
| 64 | 64 | BRP dataset in `wwwroot/brp.json`). |
| 65 | 65 | |
| 66 | +### rvrb feature (stats read off another BEAM node) | |
| 67 | + | |
| 68 | +`Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the | |
| 69 | +rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs` | |
| 70 | +gets them by joining the bot's Erlang cluster: | |
| 71 | +(referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a | |
| 72 | +hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would — | |
| 73 | +no HTTP endpoint on the Elixir side. | |
| 74 | + | |
| 75 | +The node is started on the *first request* rather than at boot, and a failure is a value | |
| 76 | +(`RvrbStatus.Unreachable`) rather than an exception: the site must not fail to start, or a page | |
| 77 | +fail to render, because EPMD is down or the bot was deployed without distribution. `Models/RvrbSnapshot.cs` | |
| 78 | +holds the shapes and `Services/RvrbSnapshotReader.cs` decodes the Erlang term into them by hand — | |
| 79 | +the term is an Elixir map written by Elixir, not a serialized C# type. | |
| 80 | + | |
| 81 | +Configuration lives under `Rvrb` (`RvrbOptions`): `Node`, `LocalNode`, `CallTimeout`, `CacheFor` | |
| 82 | +in `appsettings.json`, and `Cookie` — the shared Erlang cookie — from user secrets in development | |
| 83 | +(`dotnet user-secrets set "Rvrb:Cookie" ...`) or `Rvrb__Cookie` in the environment. The bot's own | |
| 84 | +side of this (enabling distribution on its release) is documented in the rvrb repo's README. | |
| 85 | + | |
| 66 | 86 | ### BRP feature (the one page with a real backend) |
| 67 | 87 | |
| 68 | 88 | `BRP.razor`/`.razor.cs` and `BrpTestData.razor` are backed by `Services/BrpService.cs`, |