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}