jevstrudel.git / website / src / jev / DataTab.jsx
DataTab.jsx725 lines · 26.3 KB · raw
1// The jev panel's data tab: everything the site stores, for anyone to read
2// (worker/src/data.ts serves it). A section per store, each saying what it
3// holds and why, what is shown, and what is withheld: secrets (session
4// hashes, passkey ids and keys, sign-in challenges, OAuth tokens, party room
5// names) are only ever counted and dated. People are public: every account,
6// its radio history, its takes (performances it played), what it wrote (held and pending too, with Jev's
7// verdict) and its Jev budget today.
8//
9// Everything here is other people's writing, so it is shown as text only:
10// React escapes it, a held song's code is a <pre>, and nothing here plays,
11// evaluates or renders an image of anything (a public cover is shown on the
12// songs tab, by the route that serves only public ones).
13import { useEffect, useState } from 'react';
14import { performancePath } from './performance.mjs';
15import { openProfile } from './profile.mjs';
16import { takeTime } from './takes.mjs';
17import { themeColor as ACCENT } from './site.mjs';
18
19const when = (ms) => (ms ? new Date(ms).toLocaleString() : '—');
20const pct = (x) => (typeof x === 'number' ? `${Math.round(x * 100)}%` : '—');
21const PAGE = 50;
22
23async function read(path) {
24  const res = await fetch(`/jev/data${path}`, { credentials: 'same-origin' });
25  const body = await res.json().catch(() => ({}));
26  if (!res.ok) throw new Error(body.error ?? `the site answered ${res.status}`);
27  return body;
28}
29
30// One read, again whenever `path` changes.
31function useRead(path) {
32  const [state, setState] = useState({ loading: true });
33  useEffect(() => {
34    let live = true;
35    setState({ loading: true });
36    read(path).then(
37      (body) => live && setState({ body }),
38      (e) => live && setState({ error: e.message }),
39    );
40    return () => {
41      live = false;
42    };
43  }, [path]);
44  return state;
45}
46
47// A paged list: `key` is the field of the answer holding its rows.
48function usePaged(path, key) {
49  const [rows, setRows] = useState([]);
50  const [total, setTotal] = useState(null);
51  const [error, setError] = useState(null);
52  const [busy, setBusy] = useState(false);
53  const more = async (offset) => {
54    setBusy(true);
55    try {
56      const sep = path.includes('?') ? '&' : '?';
57      const body = await read(`${path}${sep}limit=${PAGE}&offset=${offset}`);
58      setRows((r) => (offset ? [...r, ...body[key]] : body[key]));
59      setTotal(body.total);
60      setError(null);
61    } catch (e) {
62      setError(e.message);
63    } finally {
64      setBusy(false);
65    }
66  };
67  useEffect(() => {
68    setRows([]);
69    more(0);
70  }, [path]);
71  return { rows, total, error, busy, more: () => more(rows.length), done: total !== null && rows.length >= total };
72}
73
74function Verdict({ screen, confidence }) {
75  const color = screen === 'fine' ? undefined : screen === 'pending' ? '#facc15' : '#f87171';
76  return (
77    <span className="text-xs whitespace-nowrap" style={{ color }}>
78      {screen === 'fine' ? 'public' : screen}
79      {confidence !== null && confidence !== undefined && screen !== 'pending' ? ` ${pct(confidence)}` : ''}
80    </span>
81  );
82}
83
84function Status({ item }) {
85  if (item.status === 'public') return <span className="text-xs">public</span>;
86  if (item.status === 'held') return <Verdict screen={item.verdict} confidence={item.confidence} />;
87  return <span className="text-xs" style={{ color: '#facc15' }}>pending ({item.why}, attempt {item.attempts})</span>;
88}
89
90// The listener-facing profile (Profile.jsx), beside an account's raw record.
91function ProfileButton({ id }) {
92  return (
93    <button className="text-[10px] underline opacity-70 hover:opacity-100 ml-1" onClick={() => openProfile(id)}>
94      profile
95    </button>
96  );
97}
98
99function Person({ author, open }) {
100  return (
101    <button className="underline hover:opacity-70" onClick={() => open({ kind: 'account', id: author.id })}>
102      {author.name}
103    </button>
104  );
105}
106
107// A store: what it holds, what is shown and what is not, and its rows.
108function Section({ title, holds, withheld, count, children, defaultOpen = false }) {
109  return (
110    <details className="border-t border-muted py-2" open={defaultOpen}>
111      <summary className="cursor-pointer select-none">
112        <b>{title}</b>
113        {count !== undefined && count !== null && <span className="opacity-60"> · {count}</span>}
114      </summary>
115      <p className="text-xs opacity-80 mt-1">{holds}</p>
116      {withheld && <p className="text-xs opacity-60 mt-1">Not shown: {withheld}</p>}
117      <div className="mt-2">{children}</div>
118    </details>
119  );
120}
121
122function Paged({ list, children, empty = 'nothing yet' }) {
123  if (list.error) return <p className="text-xs text-red-400">{list.error}</p>;
124  return (
125    <>
126      {!list.rows.length && !list.busy && <p className="text-xs opacity-60">{empty}</p>}
127      {children(list.rows)}
128      {!list.done && (
129        <button className="text-xs underline mt-1 disabled:opacity-50" disabled={list.busy} onClick={list.more}>
130          {list.busy ? 'reading…' : `more (${list.rows.length} of ${list.total ?? '…'})`}
131        </button>
132      )}
133    </>
134  );
135}
136
137const table = 'w-full text-xs border-collapse';
138const td = 'py-0.5 pr-2 align-top border-t border-muted';
139
140// ── the lists ─────────────────────────────────────
141function Accounts({ open }) {
142  const list = usePaged('/accounts', 'accounts');
143  return (
144    <Paged list={list}>
145      {(rows) => (
146        <table className={table}>
147          <tbody>
148            {rows.map((a) => (
149              <tr key={a.id}>
150                <td className={td}>
151                  <Person author={a} open={open} />
152                  <ProfileButton id={a.id} />
153                </td>
154                <td className={td}>{when(a.joined)}</td>
155                <td className={td}>
156                  {a.passkeys} passkey{a.passkeys === 1 ? '' : 's'} · {a.sessions} session{a.sessions === 1 ? '' : 's'} ·{' '}
157                  {a.radioPlays} plays · {a.songs} songs · {a.comments} comments · {a.pitches} pitches
158                  {a.apps ? ` · ${a.apps} apps` : ''}
159                </td>
160              </tr>
161            ))}
162          </tbody>
163        </table>
164      )}
165    </Paged>
166  );
167}
168
169function Songs({ open }) {
170  const list = usePaged('/songs', 'songs');
171  return (
172    <Paged list={list}>
173      {(rows) => (
174        <table className={table}>
175          <tbody>
176            {rows.map((s) => (
177              <tr key={s.id}>
178                <td className={td}>
179                  <button className="underline hover:opacity-70 text-left" onClick={() => open({ kind: 'song', id: s.id })}>
180                    {s.newest.title}
181                  </button>
182                </td>
183                <td className={td}>
184                  <Person author={s.author} open={open} />
185                </td>
186                <td className={td}>
187                  rev {s.newest.rev} of {s.revisions} · <Verdict screen={s.newest.screen} confidence={null} />
188                  {s.newest.art !== null ? ` · ${pct(s.newest.art)} art` : ''}
189                </td>
190              </tr>
191            ))}
192          </tbody>
193        </table>
194      )}
195    </Paged>
196  );
197}
198
199function Texts({ path, keyName, open, song = false }) {
200  const list = usePaged(path, keyName);
201  return (
202    <Paged list={list}>
203      {(rows) => (
204        <ul className="space-y-2 text-xs">
205          {rows.map((r) => (
206            <li key={r.id}>
207              <div className="opacity-70">
208                <Person author={r.author} open={open} /> · {when(r.created)} · <Verdict screen={r.screen} confidence={r.confidence} />
209                {song && ` · on ${r.song}`}
210              </div>
211              <div className="whitespace-pre-wrap break-words">{r.body}</div>
212            </li>
213          ))}
214        </ul>
215      )}
216    </Paged>
217  );
218}
219
220// Every pitch, with its voters by name and the listener songs answering it.
221function PitchRows({ open }) {
222  const list = usePaged('/pitches', 'pitches');
223  return (
224    <Paged list={list}>
225      {(rows) => (
226        <ul className="space-y-2 text-xs">
227          {rows.map((r) => (
228            <li key={r.id}>
229              <div className="opacity-70">
230                <Person author={r.author} open={open} /> · {when(r.created)} · <Verdict screen={r.screen} confidence={r.confidence} />
231                {' · '}
232                {r.votes.length} {r.votes.length === 1 ? 'vote' : 'votes'}
233              </div>
234              <div className="whitespace-pre-wrap break-words">{r.body}</div>
235              {r.votes.length > 0 && (
236                <div className="opacity-70">
237                  voted for by{' '}
238                  {r.votes.map((v, i) => (
239                    <span key={v.id}>
240                      {i > 0 && ', '}
241                      <Person author={v} open={open} />
242                    </span>
243                  ))}
244                </div>
245              )}
246              {r.answeredBy.length > 0 && (
247                <div className="opacity-70">
248                  answered by{' '}
249                  {r.answeredBy.map((id, i) => (
250                    <span key={id}>
251                      {i > 0 && ', '}
252                      <button className="underline hover:opacity-70" onClick={() => open({ kind: 'song', id })}>
253                        a listener song
254                      </button>
255                    </span>
256                  ))}
257                </div>
258              )}
259            </li>
260          ))}
261        </ul>
262      )}
263    </Paged>
264  );
265}
266
267function Covers({ open }) {
268  const list = usePaged('/covers', 'covers');
269  return (
270    <Paged list={list}>
271      {(rows) => (
272        <table className={table}>
273          <tbody>
274            {rows.map((c) => (
275              <tr key={c.id}>
276                <td className={td}>{c.alt}</td>
277                <td className={td}>
278                  <Person author={c.author} open={open} />
279                </td>
280                <td className={td}>
281                  {c.contentType} · {Math.round(c.bytes / 1024)} KiB · <Verdict screen={c.screen} confidence={c.confidence} />
282                  {c.served ? ' · served' : ' · not served'}
283                </td>
284              </tr>
285            ))}
286          </tbody>
287        </table>
288      )}
289    </Paged>
290  );
291}
292
293function Jobs({ open }) {
294  const list = usePaged('/jobs', 'jobs');
295  return (
296    <Paged list={list} empty="Jev owes nothing right now">
297      {(rows) => (
298        <table className={table}>
299          <tbody>
300            {rows.map((j) => (
301              <tr key={`${j.kind}/${j.item}/${j.task}`}>
302                <td className={td}>
303                  {j.task} a {j.kind}
304                </td>
305                <td className={td}>
306                  <Person author={j.author} open={open} />
307                </td>
308                <td className={td}>
309                  {j.running ? 'running now' : `due ${when(j.due)}`} · {j.why} · attempt {j.attempts}
310                </td>
311              </tr>
312            ))}
313          </tbody>
314        </table>
315      )}
316    </Paged>
317  );
318}
319
320function Votes() {
321  const list = usePaged('/votes', 'votes');
322  return (
323    <Paged list={list}>
324      {(rows) => (
325        <table className={table}>
326          <tbody>
327            {rows.map((v) => (
328              <tr key={`${v.a}|${v.b}|${v.pick}`}>
329                <td className={td}>
330                  {v.a} vs {v.b}
331                </td>
332                <td className={td}>
333                  {v.pick}: {v.n}
334                </td>
335              </tr>
336            ))}
337          </tbody>
338        </table>
339      )}
340    </Paged>
341  );
342}
343
344function Reactions() {
345  const list = usePaged('/reactions', 'reactions');
346  return (
347    <Paged list={list}>
348      {(rows) => (
349        <table className={table}>
350          <tbody>
351            {rows.map((r) => (
352              <tr key={`${r.song}|${r.section}|${r.reaction}`}>
353                <td className={td}>{r.song}</td>
354                <td className={td}>{r.section}</td>
355                <td className={td}>
356                  {r.reaction === 'fire' ? '🔥' : '😴'} {r.n}
357                </td>
358              </tr>
359            ))}
360          </tbody>
361        </table>
362      )}
363    </Paged>
364  );
365}
366
367function Performances({ open }) {
368  const list = usePaged('/performances', 'performances');
369  return (
370    <Paged list={list}>
371      {(rows) => (
372        <table className={table}>
373          <tbody>
374            {rows.map((p) => (
375              <tr key={p.id}>
376                <td className={td}>
377                  <button className="underline hover:opacity-70" onClick={() => open({ kind: 'performance', id: p.id })}>
378                    {p.song}
379                  </button>
380                </td>
381                <td className={td}>{takeTime(p)}</td>
382                <td className={td}>{p.player ? <Person author={p.player} open={open} /> : 'signed out'}</td>
383                <td className={td}>
384                  {p.segments} decisions · {p.fallbacks} fallbacks · {p.ended}
385                </td>
386              </tr>
387            ))}
388          </tbody>
389        </table>
390      )}
391    </Paged>
392  );
393}
394
395// ── one of something ──────────────────────────────
396// A view read by path: 'reading…', the error, or `Page` with the answer.
397function Loaded({ path, Page, open }) {
398  const { body, error, loading } = useRead(path);
399  if (loading) return <p className="text-xs opacity-60">reading…</p>;
400  if (error) return <p className="text-xs text-red-400">{error}</p>;
401  return <Page body={body} open={open} />;
402}
403
404export function AccountPage({ body: a, open }) {
405  const c = a.content;
406  return (
407    <div className="space-y-3 text-xs">
408      <h3 className="text-base font-bold" style={{ color: ACCENT }}>
409        {a.name}
410        <ProfileButton id={a.id} />
411      </h3>
412      <p>
413        Joined {when(a.joined)}. Jev budget today:{' '}
414        {a.budget ? `${a.budget.used} of ${a.budget.limit} calls` : 'not known'}. Open tabs its AI can reach:{' '}
415        {a.openTabs ?? '—'}. Connected AI apps: {a.apps.count ?? '—'}
416        {a.apps.yours && ` (yours: ${a.apps.yours.map((x) => x.app).join(', ') || 'none'})`}.
417      </p>
418      <p className="opacity-70">
419        Passkeys: {a.passkeys.map((p) => `${when(p.created)} (${p.transports.join(', ') || 'no transports'})`).join('; ') || 'none'}.
420        Sessions: {a.sessions.filter((s) => s.active).length} active of {a.sessions.length}, newest{' '}
421        {when(a.sessions[0]?.created)}. (A passkey's id and key, and a session's hash, are never shown.)
422      </p>
423      <div>
424        <b>Radio history</b> ({a.radio.length})
425        <div className="opacity-80">{a.radio.map((p) => `${p.song} ${when(p.at)}`).join(' · ') || 'none'}</div>
426      </div>
427      <div>
428        <b>Takes</b> ({a.takes.length}, the newest; performances played signed in)
429        <div className="opacity-80">
430          {a.takes.length
431            ? a.takes.map((t, i) => (
432                <span key={t.id}>
433                  {i > 0 && ' · '}
434                  <button className="underline hover:opacity-70" onClick={() => open({ kind: 'performance', id: t.id })}>
435                    {t.song}
436                  </button>{' '}
437                  {takeTime(t)}
438                </span>
439              ))
440            : 'none'}
441        </div>
442      </div>
443      <div>
444        <b>Songs</b> ({c.revisions.length} revisions)
445        <ul>
446          {c.revisions.map((r) => (
447            <li key={`${r.song}/${r.rev}`}>
448              <button className="underline hover:opacity-70" onClick={() => open({ kind: 'song', id: r.song })}>
449                {r.title}
450              </button>{' '}
451              rev {r.rev} · {when(r.at)} · <Status item={r} />
452            </li>
453          ))}
454        </ul>
455      </div>
456      <div>
457        <b>Comments</b> ({c.comments.length})
458        <ul className="space-y-1">
459          {c.comments.map((m) => (
460            <li key={m.id}>
461              <span className="opacity-70">
462                on {m.song} · {when(m.at)} · <Status item={m} />
463              </span>
464              <div className="whitespace-pre-wrap break-words">{m.body}</div>
465            </li>
466          ))}
467        </ul>
468      </div>
469      <div>
470        <b>Pitches</b> ({c.pitches.length})
471        <ul className="space-y-1">
472          {c.pitches.map((m) => (
473            <li key={m.id}>
474              <span className="opacity-70">
475                {when(m.at)} · <Status item={m} />
476              </span>
477              <div className="whitespace-pre-wrap break-words">{m.body}</div>
478            </li>
479          ))}
480        </ul>
481      </div>
482      <div>
483        <b>Covers</b> ({c.covers.length})
484        <ul>
485          {c.covers.map((m) => (
486            <li key={m.id}>
487              {m.alt} · <Status item={m} />
488            </li>
489          ))}
490        </ul>
491      </div>
492    </div>
493  );
494}
495
496export function SongPage({ body: s, open }) {
497  return (
498    <div className="space-y-3 text-xs">
499      <h3 className="text-base font-bold" style={{ color: ACCENT }}>
500        {s.revisions[0]?.title}
501      </h3>
502      <p>
503        By <Person author={s.author} open={open} />, since {when(s.created)}.
504        {s.cover && (
505          <>
506            {' '}
507            Cover: "{s.cover.alt}", <Verdict screen={s.cover.screen} confidence={s.cover.confidence} />.
508          </>
509        )}
510      </p>
511      <p className="opacity-60">Every revision as stored, as text. Nothing here plays: a public song plays from the songs tab.</p>
512      {s.revisions.map((r) => (
513        <details key={r.rev} className="border-t border-muted pt-1" open={r.rev === s.revisions[0].rev}>
514          <summary className="cursor-pointer">
515            rev {r.rev}: {r.title} · {when(r.created)} · <Verdict screen={r.screen} confidence={r.confidence} />
516            {r.art !== null ? ` · ${pct(r.art)} art` : ''}
517          </summary>
518          {r.description && <p className="mt-1">{r.description}</p>}
519          {r.spec && <pre className="mt-1 whitespace-pre-wrap bg-background p-2 rounded">{r.spec}</pre>}
520          <pre className="mt-1 overflow-x-auto bg-background p-2 rounded">{r.code}</pre>
521        </details>
522      ))}
523    </div>
524  );
525}
526
527export function PerformancePage({ body: p, open }) {
528  const answer = (a) =>
529    Object.entries(a)
530      .map(([q, v]) => `${q} ${v.choice ?? (typeof v.score === 'number' ? v.score.toFixed(1) : typeof v.noul === 'number' ? pct(v.noul) : '—')}`)
531      .join(' · ');
532  return (
533    <div className="space-y-2 text-xs">
534      <h3 className="text-base font-bold" style={{ color: ACCENT }}>
535        {p.song}, {takeTime(p)}
536      </h3>
537      <p>
538        played by {p.player ? <Person author={p.player} open={open} /> : 'someone signed out'}; {p.model}, every {p.every}{' '}
539        cycles, {p.ended}.{' '}
540        <a className="underline" href={performancePath(p.song, p.id)}>
541          replay it
542        </a>
543      </p>
544      <table className={table}>
545        <tbody>
546          {p.segments.map((s) => (
547            <tr key={`${s.jev}/${s.segment}`}>
548              <td className={td}>
549                {s.segment + 1}
550                {p.segments.some((x) => x.jev > 0) ? `.${s.jev}` : ''}
551              </td>
552              <td className={td}>{s.section ?? ''}</td>
553              <td className={td} style={{ color: s.status === 'fallback' ? '#f87171' : undefined }}>
554                {s.status}
555                {s.ms !== null ? ` · ${s.ms} ms` : ''}
556              </td>
557              <td className={td}>{answer(s.answers)}</td>
558            </tr>
559          ))}
560        </tbody>
561      </table>
562    </div>
563  );
564}
565
566// ── the tab ───────────────────────────────────────
567export function DataTab() {
568  const [stack, setStack] = useState([]);
569  const open = (view) => setStack((s) => [...s, view]);
570  const back = () => setStack((s) => s.slice(0, -1));
571  const view = stack[stack.length - 1];
572  const summary = useRead('');
573
574  if (view) {
575    return (
576      <div className="px-4 py-3 font-mono">
577        <button className="text-xs underline mb-3" onClick={back}>
578          ← back
579        </button>
580        {view.kind === 'account' && <Loaded path={`/accounts/${view.id}`} Page={AccountPage} open={open} />}
581        {view.kind === 'song' && <Loaded path={`/songs/${view.id}`} Page={SongPage} open={open} />}
582        {view.kind === 'performance' && <Loaded path={`/performances/${view.id}`} Page={PerformancePage} open={open} />}
583      </div>
584    );
585  }
586
587  const t = summary.body;
588  const d1 = t?.d1;
589  const screens = (o) =>
590    o
591      ? Object.entries(o)
592          .map(([k, n]) => `${n} ${k === 'fine' ? 'public' : k}`)
593          .join(', ') || 'none'
594      : '';
595  return (
596    <div className="px-4 py-3 font-mono text-sm" data-testid="jev-data">
597      <p className="text-xs opacity-80 mb-2">
598        Everything jevstrudel stores, for anyone to read: accounts, what they wrote (held and pending too, with Jev's
599        verdicts), radio history, budgets, listening, and what is live. Secrets are only counted and dated: session hashes,
600        passkey ids and keys, sign-in challenges, OAuth tokens and party room names.
601      </p>
602      {summary.error && <p className="text-xs text-red-400">{summary.error}</p>}
603
604      <Section
605        title="accounts"
606        count={d1?.users}
607        defaultOpen
608        holds="users, credentials, sessions (D1): each account is a random id and the display name its owner picked; it signs in with passkeys and keeps a session per browser. Click one for everything about it."
609        withheld="a passkey's id and public key, and a session's cookie token or its hash."
610      >
611        <Accounts open={open} />
612        {d1 && (
613          <p className="text-xs opacity-70 mt-2">
614            {d1.credentials} passkeys · {d1.sessions.active} active sessions of {d1.sessions.rows} stored ·{' '}
615            {d1.radioPlays} radio plays · sign-ins in progress:{' '}
616            {Object.entries(d1.challenges)
617              .map(([k, v]) => `${v.live} ${k} of ${v.rows}`)
618              .join(', ') || 'none'}{' '}
619            (auth_challenges: counted only; each is used once, within five minutes)
620          </p>
621        )}
622      </Section>
623
624      <Section
625        title="listeners' songs"
626        count={d1?.content.songs}
627        holds={`listener_songs, song_revisions (D1): songs signed-in listeners publish, every revision whole, with Jev's screening verdict (${screens(d1?.content.revisions)}) and the art critic's score.`}
628      >
629        <Songs open={open} />
630      </Section>
631      <Section
632        title="covers"
633        holds={`covers (D1) and their images (R2): a listener song's cover, screened by its alt text (${screens(d1?.content.covers)}).${t?.r2?.covers ? ` R2 holds ${t.r2.covers.objects} images, ${Math.round(t.r2.covers.bytes / 1024)} KiB.` : ''}`}
634        withheld="the image of a cover that is not public (held or pending); a public one shows on its song."
635      >
636        <Covers open={open} />
637      </Section>
638      <Section
639        title="comments"
640        holds={`comments (D1): on any song, screened by Jev (${screens(d1?.content.comments)}).`}
641      >
642        <Texts path="/comments" keyName="comments" open={open} song />
643      </Section>
644      <Section
645        title="pitches"
646        holds={`pitches, pitch_votes (D1): song ideas for Jev, screened (${screens(d1?.content.pitches)}), and ${
647          d1?.content.pitchVotes ?? 0
648        } votes for them: who voted for each, and when, and the listener songs whose authors say they answer it.`}
649      >
650        <PitchRows open={open} />
651      </Section>
652      <Section
653        title="Jev's queue"
654        holds="content_jobs (D1): the screening and scoring Jev still owes, whose daily budget pays, and why it is waiting."
655      >
656        <Jobs open={open} />
657      </Section>
658
659      <Section
660        title="performances"
661        count={d1?.performances}
662        holds={`performances, performance_segments (D1): each play of a song (a site song's or a listener's) that got past its opening, who played it (signed in) and when, and every decision Jev made in it (${d1?.segments.rows ?? '…'} decisions, ${d1?.segments.fallbacks ?? '…'} fallbacks).`}
663      >
664        <Performances open={open} />
665      </Section>
666      <Section
667        title="reactions"
668        holds={`reactions (D1): every 🔥/😴, a count per song and section (${d1?.reactions.count ?? '…'} in all). Anonymous.`}
669      >
670        <Reactions />
671      </Section>
672      <Section
673        title="votes"
674        holds={`votes (D1): "do you agree with Jev?", a count per pair and pick (${d1?.votes.count ?? '…'} in all). Anonymous: no time, no voter.`}
675      >
676        <Votes />
677      </Section>
678
679      <Section
680        title="live now"
681        holds="Durable Objects: the lobby (pages open, tabs listed), listening parties, each account's daily Jev budget and its tabs (on its page). Nothing here outlives its moment."
682        withheld="a party's room name (its join link) and its host's key."
683      >
684        {t && (
685          <div className="text-xs space-y-1">
686            <p>
687              Lobby: {t.live.lobby ? `${t.live.lobby.open} pages open, ${t.live.lobby.listed} tabs listed, ${t.live.lobby.playing} playing` : 'not reachable'}.
688            </p>
689            <p>
690              Parties:{' '}
691              {t.live.parties
692                ? t.live.parties.length
693                  ? t.live.parties.map((p) => `${p.song ?? 'no song yet'} (${p.people} in it${p.host ? '' : ', host away'}, since ${when(p.since)})`).join('; ')
694                  : 'none'
695                : 'not reachable'}
696              .
697            </p>
698            <p>Jev budget: {t.budgetPerDay} calls a day for each account; each account's use today is on its page.</p>
699          </div>
700        )}
701      </Section>
702      <Section
703        title="caches and connections"
704        holds="KV: Jev's answers, kept an hour so the same request is not paid for twice; and the OAuth grants of AI apps people connected."
705        withheld="a cached answer's key (a hash of its request), and every token, code and grant's contents; an app's name shows only to the account that connected it."
706      >
707        {t && (
708          <div className="text-xs space-y-1">
709            <p>
710              Answer cache: {t.kv.answerCache ? `${t.kv.answerCache.entries}${t.kv.answerCache.complete ? '' : '+'} answers kept` : 'not reachable'}.
711            </p>
712            <p>
713              Connected AI apps:{' '}
714              {t.kv.oauthGrants
715                ? `${t.kv.oauthGrants.grants}${t.kv.oauthGrants.complete ? '' : '+'} grants, across ${t.kv.oauthGrants.accounts} accounts`
716                : 'not reachable'}
717              .
718            </p>
719            <p className="opacity-60">Counted {when(t.countedAt)}; counted again every ten minutes at most.</p>
720          </div>
721        )}
722      </Section>
723    </div>
724  );
725}