chore: reduce redis overhead and add upstash to costs (#653)

- increase docket heartbeat interval from 2s to 30s
- reduces redis commands by ~15x (heartbeat tracks 12 tasks)
- dead worker detection: 10s → 2.5min (acceptable for 5-min perpetual task)

- add upstash to /costs dashboard
- track redis usage in cost breakdown
- currently free tier (256MB, 500K commands/month)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub b7077079 f8e0a21c

Changed files
+254 -299
backend
src
backend
_internal
frontend
src
routes
costs
scripts
+3
backend/src/backend/_internal/background.py
··· 58 async with Docket( 59 name=settings.docket.name, 60 url=settings.docket.url, 61 ) as docket: 62 _docket = docket 63
··· 58 async with Docket( 59 name=settings.docket.name, 60 url=settings.docket.url, 61 + # default 2s is for systems needing fast worker failure detection 62 + # with our 5-minute perpetual task, 30s is plenty responsive 63 + heartbeat_interval=timedelta(seconds=30), 64 ) as docket: 65 _docket = docket 66
+240 -298
frontend/src/routes/costs/+page.svelte
··· 35 breakdown: CostBreakdown; 36 note: string; 37 }; 38 audd: { 39 amount: number; 40 base_cost: number; ··· 93 data.costs.fly_io.amount, 94 data.costs.neon.amount, 95 data.costs.cloudflare.amount, 96 data.costs.audd.amount 97 ) 98 : 1 99 ); 100 101 - let maxRequests = $derived.by(() => { 102 - return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 103 - }); 104 105 - onMount(async () => { 106 - try { 107 - const response = await fetch(`${API_URL}/stats/costs`); 108 - if (!response.ok) { 109 - throw new Error(`failed to load cost data: ${response.status}`); 110 - } 111 - data = await response.json(); 112 - } catch (e) { 113 - console.error('failed to load costs:', e); 114 - error = e instanceof Error ? e.message : 'failed to load cost data'; 115 - } finally { 116 - loading = false; 117 - } 118 - }); 119 120 function formatDate(isoString: string): string { 121 - const date = new Date(isoString); 122 - return date.toLocaleDateString('en-US', { 123 month: 'short', 124 day: 'numeric', 125 - year: 'numeric', 126 hour: 'numeric', 127 minute: '2-digit' 128 }); 129 } 130 131 - function formatCurrency(amount: number): string { 132 - return `$${amount.toFixed(2)}`; 133 } 134 135 - // calculate bar width as percentage of max 136 function barWidth(amount: number, max: number): number { 137 - return Math.max(5, (amount / max) * 100); 138 } 139 140 async function logout() { 141 await auth.logout(); ··· 222 <span class="cost-note">{data.costs.cloudflare.note}</span> 223 </div> 224 225 <div class="cost-item"> 226 <div class="cost-header"> 227 <span class="cost-name">audd</span> ··· 240 241 <!-- audd details --> 242 <section class="audd-section"> 243 - <div class="audd-header"> 244 - <h2>api requests (audd)</h2> 245 - <div class="time-range-toggle"> 246 - <button 247 - class:active={timeRange === 'day'} 248 - onclick={() => (timeRange = 'day')} 249 - > 250 - 24h 251 - </button> 252 - <button 253 - class:active={timeRange === 'week'} 254 - onclick={() => (timeRange = 'week')} 255 - > 256 - 7d 257 - </button> 258 - <button 259 - class:active={timeRange === 'month'} 260 - onclick={() => (timeRange = 'month')} 261 - > 262 - 30d 263 - </button> 264 - </div> 265 - </div> 266 267 <div class="audd-stats"> 268 <div class="stat"> 269 - <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 270 - <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 271 </div> 272 <div class="stat"> 273 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 274 <span class="stat-label">free remaining</span> 275 </div> 276 <div class="stat"> 277 - <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 278 - <span class="stat-label">tracks scanned</span> 279 </div> 280 </div> 281 282 - <p class="audd-explainer"> 283 - 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 284 - then ${(5).toFixed(2)}/1k requests. 285 - {#if data.costs.audd.billable_requests > 0} 286 - <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 287 - {/if} 288 - </p> 289 - 290 - {#if filteredDaily.length > 0} 291 - <div class="daily-chart"> 292 - <h3>daily requests</h3> 293 - <div class="chart-bars"> 294 {#each filteredDaily as day} 295 - <div class="chart-bar-container"> 296 <div 297 - class="chart-bar" 298 - style="height: {Math.max(4, (day.requests / maxRequests) * 100)}%" 299 - title="{day.date}: {day.requests} requests ({day.scans} tracks)" 300 ></div> 301 - <span class="chart-label">{day.date.slice(5)}</span> 302 </div> 303 {/each} 304 </div> 305 </div> 306 - {:else} 307 - <p class="no-data">no requests in this time range</p> 308 {/if} 309 </section> 310 311 <!-- support cta --> 312 <section class="support-section"> 313 - <div class="support-card"> 314 - <div class="support-icon"> 315 - <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 316 - <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 317 - </svg> 318 - </div> 319 - <div class="support-text"> 320 - <h3>support {APP_NAME}</h3> 321 - <p>{data.support.message}</p> 322 - </div> 323 - <a href={data.support.url} target="_blank" rel="noopener" class="support-button"> 324 - support 325 - </a> 326 - </div> 327 </section> 328 - 329 - <!-- footer note --> 330 - <p class="footer-note"> 331 - {APP_NAME} is an open-source project. 332 - <a href="https://github.com/zzstoatzz/plyr.fm" target="_blank" rel="noopener">view source</a> 333 - </p> 334 {/if} 335 </main> 336 ··· 338 main { 339 max-width: 600px; 340 margin: 0 auto; 341 - padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)); 342 } 343 344 .page-header { ··· 346 } 347 348 .page-header h1 { 349 - font-size: var(--text-page-heading); 350 - margin: 0 0 0.5rem; 351 } 352 353 .subtitle { 354 - color: var(--text-tertiary); 355 - font-size: 0.9rem; 356 margin: 0; 357 } 358 359 - .loading { 360 - display: flex; 361 - justify-content: center; 362 - padding: 4rem 0; 363 - } 364 - 365 .error-state { 366 text-align: center; 367 padding: 3rem 1rem; 368 - color: var(--text-secondary); 369 } 370 371 - .error-state .hint { 372 - color: var(--text-tertiary); 373 - font-size: 0.85rem; 374 - margin-top: 0.5rem; 375 } 376 377 /* total section */ 378 .total-section { 379 margin-bottom: 2rem; 380 } 381 382 .total-card { 383 display: flex; 384 flex-direction: column; 385 - align-items: center; 386 - padding: 2rem; 387 - background: var(--bg-tertiary); 388 - border: 1px solid var(--border-subtle); 389 - border-radius: 12px; 390 } 391 392 .total-label { 393 - font-size: 0.8rem; 394 - text-transform: uppercase; 395 - letter-spacing: 0.08em; 396 - color: var(--text-tertiary); 397 - margin-bottom: 0.5rem; 398 } 399 400 .total-amount { 401 - font-size: 3rem; 402 font-weight: 700; 403 color: var(--accent); 404 } 405 406 .updated { 407 - text-align: center; 408 font-size: 0.75rem; 409 - color: var(--text-tertiary); 410 - margin-top: 0.75rem; 411 } 412 413 /* breakdown section */ ··· 415 margin-bottom: 2rem; 416 } 417 418 - .breakdown-section h2, 419 - .audd-section h2 { 420 - font-size: 0.8rem; 421 - text-transform: uppercase; 422 - letter-spacing: 0.08em; 423 - color: var(--text-tertiary); 424 - margin-bottom: 1rem; 425 } 426 427 .cost-bars { ··· 431 } 432 433 .cost-item { 434 - background: var(--bg-tertiary); 435 - border: 1px solid var(--border-subtle); 436 - border-radius: 8px; 437 - padding: 1rem; 438 } 439 440 .cost-header { 441 display: flex; 442 justify-content: space-between; 443 - align-items: center; 444 - margin-bottom: 0.5rem; 445 } 446 447 .cost-name { 448 - font-weight: 600; 449 - color: var(--text-primary); 450 } 451 452 .cost-amount { 453 font-weight: 600; 454 - color: var(--accent); 455 - font-variant-numeric: tabular-nums; 456 } 457 458 .cost-bar-bg { 459 height: 8px; 460 - background: var(--bg-primary); 461 border-radius: 4px; 462 overflow: hidden; 463 - margin-bottom: 0.5rem; 464 } 465 466 .cost-bar { ··· 471 } 472 473 .cost-bar.audd { 474 - background: var(--warning); 475 } 476 477 .cost-note { 478 font-size: 0.75rem; 479 - color: var(--text-tertiary); 480 } 481 482 /* audd section */ ··· 484 margin-bottom: 2rem; 485 } 486 487 - .audd-header { 488 - display: flex; 489 - justify-content: space-between; 490 - align-items: center; 491 - margin-bottom: 1rem; 492 - gap: 1rem; 493 } 494 495 - .audd-header h2 { 496 - margin-bottom: 0; 497 } 498 499 - .time-range-toggle { 500 display: flex; 501 gap: 0.25rem; 502 - background: var(--bg-tertiary); 503 - border: 1px solid var(--border-subtle); 504 - border-radius: 6px; 505 - padding: 0.25rem; 506 } 507 508 - .time-range-toggle button { 509 - padding: 0.35rem 0.75rem; 510 - font-family: inherit; 511 font-size: 0.75rem; 512 - font-weight: 500; 513 - background: transparent; 514 - border: none; 515 - border-radius: 4px; 516 color: var(--text-secondary); 517 - cursor: pointer; 518 - transition: all 0.15s; 519 } 520 521 - .time-range-toggle button:hover { 522 - color: var(--text-primary); 523 } 524 525 - .time-range-toggle button.active { 526 - background: var(--accent); 527 - color: white; 528 } 529 530 - .no-data { 531 - text-align: center; 532 - color: var(--text-tertiary); 533 - font-size: 0.85rem; 534 - padding: 2rem; 535 - background: var(--bg-tertiary); 536 - border: 1px solid var(--border-subtle); 537 - border-radius: 8px; 538 } 539 540 - .audd-stats { 541 - display: grid; 542 - grid-template-columns: repeat(3, 1fr); 543 - gap: 1rem; 544 - margin-bottom: 1rem; 545 } 546 547 - .audd-explainer { 548 - font-size: 0.8rem; 549 - color: var(--text-secondary); 550 - margin-bottom: 1.5rem; 551 - line-height: 1.5; 552 } 553 554 - .audd-explainer strong { 555 - color: var(--warning); 556 } 557 558 - .stat { 559 - display: flex; 560 - flex-direction: column; 561 - align-items: center; 562 - padding: 1rem; 563 - background: var(--bg-tertiary); 564 - border: 1px solid var(--border-subtle); 565 - border-radius: 8px; 566 } 567 568 - .stat-value { 569 - font-size: 1.25rem; 570 - font-weight: 700; 571 color: var(--text-primary); 572 - font-variant-numeric: tabular-nums; 573 } 574 575 - .stat-label { 576 - font-size: 0.7rem; 577 - color: var(--text-tertiary); 578 - text-align: center; 579 - margin-top: 0.25rem; 580 - } 581 - 582 - /* daily chart */ 583 - .daily-chart { 584 - background: var(--bg-tertiary); 585 - border: 1px solid var(--border-subtle); 586 - border-radius: 8px; 587 - padding: 1rem; 588 - overflow: hidden; 589 } 590 591 - .daily-chart h3 { 592 - font-size: 0.75rem; 593 - text-transform: uppercase; 594 - letter-spacing: 0.05em; 595 - color: var(--text-tertiary); 596 - margin: 0 0 1rem; 597 } 598 599 - .chart-bars { 600 display: flex; 601 - align-items: flex-end; 602 gap: 2px; 603 - height: 100px; 604 - width: 100%; 605 } 606 607 - .chart-bar-container { 608 - flex: 1 1 0; 609 - min-width: 0; 610 display: flex; 611 flex-direction: column; 612 align-items: center; 613 height: 100%; 614 } 615 616 - .chart-bar { 617 width: 100%; 618 background: var(--accent); 619 border-radius: 2px 2px 0 0; 620 - min-height: 4px; 621 - margin-top: auto; 622 - transition: height 0.3s ease; 623 } 624 625 - .chart-bar:hover { 626 opacity: 0.8; 627 } 628 629 - .chart-label { 630 - font-size: 0.55rem; 631 - color: var(--text-tertiary); 632 - margin-top: 0.25rem; 633 white-space: nowrap; 634 - overflow: hidden; 635 - text-overflow: ellipsis; 636 - max-width: 100%; 637 } 638 639 /* support section */ 640 .support-section { 641 - margin-bottom: 2rem; 642 - } 643 - 644 - .support-card { 645 - display: flex; 646 - flex-direction: column; 647 - align-items: center; 648 text-align: center; 649 - padding: 2rem; 650 - background: linear-gradient(135deg, 651 - color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)), 652 - var(--bg-tertiary) 653 - ); 654 - border: 1px solid var(--border-subtle); 655 border-radius: 12px; 656 } 657 658 - .support-icon { 659 - color: var(--accent); 660 - margin-bottom: 1rem; 661 - } 662 - 663 - .support-text h3 { 664 - margin: 0 0 0.5rem; 665 - font-size: 1.1rem; 666 - color: var(--text-primary); 667 - } 668 - 669 - .support-text p { 670 - margin: 0 0 1.5rem; 671 color: var(--text-secondary); 672 - font-size: 0.9rem; 673 } 674 675 - .support-button { 676 - display: inline-flex; 677 - align-items: center; 678 - gap: 0.5rem; 679 - padding: 0.75rem 1.5rem; 680 - background: var(--accent); 681 - color: white; 682 - border-radius: 8px; 683 - text-decoration: none; 684 - font-weight: 600; 685 - font-size: 0.9rem; 686 - transition: transform 0.15s, box-shadow 0.15s; 687 - } 688 - 689 - .support-button:hover { 690 - transform: translateY(-2px); 691 - box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 692 - } 693 - 694 - /* footer */ 695 - .footer-note { 696 - text-align: center; 697 - font-size: 0.8rem; 698 - color: var(--text-tertiary); 699 - padding-bottom: 1rem; 700 - } 701 - 702 - .footer-note a { 703 color: var(--accent); 704 text-decoration: none; 705 } 706 707 - .footer-note a:hover { 708 text-decoration: underline; 709 } 710 711 @media (max-width: 480px) { 712 .total-amount { 713 - font-size: 2.5rem; 714 } 715 716 .audd-stats { 717 - grid-template-columns: 1fr; 718 } 719 720 - .chart-label { 721 display: none; 722 } 723 }
··· 35 breakdown: CostBreakdown; 36 note: string; 37 }; 38 + upstash?: { 39 + amount: number; 40 + note: string; 41 + }; 42 audd: { 43 amount: number; 44 base_cost: number; ··· 97 data.costs.fly_io.amount, 98 data.costs.neon.amount, 99 data.costs.cloudflare.amount, 100 + data.costs.upstash?.amount ?? 0, 101 data.costs.audd.amount 102 ) 103 : 1 104 ); 105 106 + // derive max requests for the daily chart based on filtered data 107 + let maxRequests = $derived( 108 + filteredDaily.length > 0 ? Math.max(...filteredDaily.map((d) => d.requests), 1) : 1 109 + ); 110 111 + function formatCurrency(amount: number): string { 112 + return `$${amount.toFixed(2)}`; 113 + } 114 115 function formatDate(isoString: string): string { 116 + return new Date(isoString).toLocaleString('en-US', { 117 month: 'short', 118 day: 'numeric', 119 hour: 'numeric', 120 minute: '2-digit' 121 }); 122 } 123 124 + function formatShortDate(isoString: string): string { 125 + return new Date(isoString).toLocaleString('en-US', { 126 + month: 'short', 127 + day: 'numeric' 128 + }); 129 } 130 131 function barWidth(amount: number, max: number): number { 132 + if (max === 0) return 0; 133 + return Math.max((amount / max) * 100, 2); // minimum 2% for visibility 134 } 135 + 136 + onMount(async () => { 137 + try { 138 + const res = await fetch(`${API_URL}/stats/costs`); 139 + if (!res.ok) throw new Error('failed to load costs'); 140 + data = await res.json(); 141 + } catch (e) { 142 + error = e instanceof Error ? e.message : 'unknown error'; 143 + } finally { 144 + loading = false; 145 + } 146 + }); 147 148 async function logout() { 149 await auth.logout(); ··· 230 <span class="cost-note">{data.costs.cloudflare.note}</span> 231 </div> 232 233 + {#if data.costs.upstash} 234 + <div class="cost-item"> 235 + <div class="cost-header"> 236 + <span class="cost-name">upstash</span> 237 + <span class="cost-amount">{formatCurrency(data.costs.upstash.amount)}</span> 238 + </div> 239 + <div class="cost-bar-bg"> 240 + <div 241 + class="cost-bar" 242 + style="width: {barWidth(data.costs.upstash.amount, maxCost)}%" 243 + ></div> 244 + </div> 245 + <span class="cost-note">{data.costs.upstash.note}</span> 246 + </div> 247 + {/if} 248 + 249 <div class="cost-item"> 250 <div class="cost-header"> 251 <span class="cost-name">audd</span> ··· 264 265 <!-- audd details --> 266 <section class="audd-section"> 267 + <h2>copyright detection (audd)</h2> 268 269 <div class="audd-stats"> 270 <div class="stat"> 271 + <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 272 + <span class="stat-label">tracks scanned</span> 273 + </div> 274 + <div class="stat"> 275 + <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 276 + <span class="stat-label">api requests</span> 277 </div> 278 <div class="stat"> 279 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 280 <span class="stat-label">free remaining</span> 281 </div> 282 <div class="stat"> 283 + <span class="stat-value">{data.costs.audd.flag_rate}%</span> 284 + <span class="stat-label">flag rate</span> 285 </div> 286 </div> 287 288 + <!-- daily chart with time range toggle --> 289 + {#if data.costs.audd.daily.length > 0} 290 + <div class="daily-chart-container"> 291 + <div class="chart-header"> 292 + <h3>daily requests</h3> 293 + <div class="time-toggle"> 294 + <button 295 + class="toggle-btn" 296 + class:active={timeRange === 'day'} 297 + onclick={() => (timeRange = 'day')} 298 + > 299 + 24h 300 + </button> 301 + <button 302 + class="toggle-btn" 303 + class:active={timeRange === 'week'} 304 + onclick={() => (timeRange = 'week')} 305 + > 306 + 7d 307 + </button> 308 + <button 309 + class="toggle-btn" 310 + class:active={timeRange === 'month'} 311 + onclick={() => (timeRange = 'month')} 312 + > 313 + 30d 314 + </button> 315 + </div> 316 + </div> 317 + <div class="chart-summary"> 318 + <span>{filteredTotals.requests.toLocaleString()} requests</span> 319 + <span class="separator">·</span> 320 + <span>{filteredTotals.scans.toLocaleString()} scans</span> 321 + </div> 322 + <div class="daily-chart"> 323 {#each filteredDaily as day} 324 + <div class="day-bar-container"> 325 <div 326 + class="day-bar" 327 + style="height: {barWidth(day.requests, maxRequests)}%" 328 + title="{formatShortDate(day.date)}: {day.requests} requests, {day.scans} scans" 329 ></div> 330 + <span class="day-label">{formatShortDate(day.date)}</span> 331 </div> 332 {/each} 333 </div> 334 </div> 335 {/if} 336 </section> 337 338 <!-- support cta --> 339 <section class="support-section"> 340 + <p>{data.support.message}</p> 341 + <a href={data.support.url} target="_blank" rel="noopener noreferrer" class="support-link"> 342 + become a supporter → 343 + </a> 344 </section> 345 {/if} 346 </main> 347 ··· 349 main { 350 max-width: 600px; 351 margin: 0 auto; 352 + padding: 2rem 1rem 6rem; 353 } 354 355 .page-header { ··· 357 } 358 359 .page-header h1 { 360 + font-size: 1.5rem; 361 + font-weight: 600; 362 + margin: 0 0 0.25rem; 363 } 364 365 .subtitle { 366 + color: var(--text-secondary); 367 + font-size: 0.875rem; 368 margin: 0; 369 } 370 371 + .loading, 372 .error-state { 373 text-align: center; 374 padding: 3rem 1rem; 375 } 376 377 + .error-state p { 378 + margin: 0.5rem 0; 379 + } 380 + 381 + .hint { 382 + color: var(--text-secondary); 383 + font-size: 0.875rem; 384 } 385 386 /* total section */ 387 .total-section { 388 + text-align: center; 389 margin-bottom: 2rem; 390 } 391 392 .total-card { 393 + background: var(--surface); 394 + border: 1px solid var(--border); 395 + border-radius: 12px; 396 + padding: 1.5rem; 397 display: flex; 398 flex-direction: column; 399 + gap: 0.5rem; 400 } 401 402 .total-label { 403 + font-size: 0.875rem; 404 + color: var(--text-secondary); 405 + text-transform: lowercase; 406 } 407 408 .total-amount { 409 + font-size: 2.5rem; 410 font-weight: 700; 411 color: var(--accent); 412 } 413 414 .updated { 415 font-size: 0.75rem; 416 + color: var(--text-secondary); 417 + margin-top: 0.5rem; 418 } 419 420 /* breakdown section */ ··· 422 margin-bottom: 2rem; 423 } 424 425 + .breakdown-section h2 { 426 + font-size: 1rem; 427 + font-weight: 600; 428 + margin: 0 0 1rem; 429 + text-transform: lowercase; 430 } 431 432 .cost-bars { ··· 436 } 437 438 .cost-item { 439 + display: flex; 440 + flex-direction: column; 441 + gap: 0.25rem; 442 } 443 444 .cost-header { 445 display: flex; 446 justify-content: space-between; 447 + align-items: baseline; 448 } 449 450 .cost-name { 451 + font-weight: 500; 452 + font-size: 0.875rem; 453 } 454 455 .cost-amount { 456 font-weight: 600; 457 + font-size: 0.875rem; 458 } 459 460 .cost-bar-bg { 461 height: 8px; 462 + background: var(--surface); 463 border-radius: 4px; 464 overflow: hidden; 465 } 466 467 .cost-bar { ··· 472 } 473 474 .cost-bar.audd { 475 + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); 476 } 477 478 .cost-note { 479 font-size: 0.75rem; 480 + color: var(--text-secondary); 481 } 482 483 /* audd section */ ··· 485 margin-bottom: 2rem; 486 } 487 488 + .audd-section h2 { 489 + font-size: 1rem; 490 + font-weight: 600; 491 + margin: 0 0 1rem; 492 + text-transform: lowercase; 493 } 494 495 + .audd-stats { 496 + display: grid; 497 + grid-template-columns: repeat(2, 1fr); 498 + gap: 1rem; 499 + margin-bottom: 1.5rem; 500 } 501 502 + .stat { 503 + background: var(--surface); 504 + border: 1px solid var(--border); 505 + border-radius: 8px; 506 + padding: 1rem; 507 display: flex; 508 + flex-direction: column; 509 gap: 0.25rem; 510 + } 511 + 512 + .stat-value { 513 + font-size: 1.25rem; 514 + font-weight: 600; 515 } 516 517 + .stat-label { 518 font-size: 0.75rem; 519 color: var(--text-secondary); 520 + text-transform: lowercase; 521 } 522 523 + /* daily chart */ 524 + .daily-chart-container { 525 + background: var(--surface); 526 + border: 1px solid var(--border); 527 + border-radius: 8px; 528 + padding: 1rem; 529 } 530 531 + .daily-chart-container h3 { 532 + font-size: 0.875rem; 533 + font-weight: 500; 534 + margin: 0 0 0.75rem; 535 + text-transform: lowercase; 536 } 537 538 + .chart-header { 539 + display: flex; 540 + justify-content: space-between; 541 + align-items: center; 542 + margin-bottom: 0.5rem; 543 } 544 545 + .chart-header h3 { 546 + margin: 0; 547 } 548 549 + .time-toggle { 550 + display: flex; 551 + gap: 0.25rem; 552 + background: var(--background); 553 + border-radius: 6px; 554 + padding: 2px; 555 } 556 557 + .toggle-btn { 558 + background: transparent; 559 + border: none; 560 + padding: 0.25rem 0.5rem; 561 + font-size: 0.75rem; 562 + color: var(--text-secondary); 563 + cursor: pointer; 564 + border-radius: 4px; 565 + transition: 566 + background 0.15s, 567 + color 0.15s; 568 } 569 570 + .toggle-btn:hover { 571 + color: var(--text-primary); 572 } 573 574 + .toggle-btn.active { 575 + background: var(--surface); 576 color: var(--text-primary); 577 + font-weight: 500; 578 } 579 580 + .chart-summary { 581 + font-size: 0.75rem; 582 + color: var(--text-secondary); 583 + margin-bottom: 0.75rem; 584 } 585 586 + .chart-summary .separator { 587 + margin: 0 0.5rem; 588 } 589 590 + .daily-chart { 591 display: flex; 592 gap: 2px; 593 + height: 80px; 594 + align-items: flex-end; 595 + overflow-x: auto; 596 } 597 598 + .day-bar-container { 599 + flex: 1; 600 + min-width: 20px; 601 display: flex; 602 flex-direction: column; 603 align-items: center; 604 + gap: 4px; 605 height: 100%; 606 } 607 608 + .day-bar { 609 width: 100%; 610 background: var(--accent); 611 border-radius: 2px 2px 0 0; 612 + min-height: 2px; 613 + cursor: help; 614 + transition: opacity 0.15s; 615 } 616 617 + .day-bar:hover { 618 opacity: 0.8; 619 } 620 621 + .day-label { 622 + font-size: 0.625rem; 623 + color: var(--text-secondary); 624 white-space: nowrap; 625 } 626 627 /* support section */ 628 .support-section { 629 text-align: center; 630 + padding: 1.5rem; 631 + background: var(--surface); 632 + border: 1px solid var(--border); 633 border-radius: 12px; 634 } 635 636 + .support-section p { 637 + margin: 0 0 1rem; 638 color: var(--text-secondary); 639 + font-size: 0.875rem; 640 } 641 642 + .support-link { 643 + display: inline-block; 644 color: var(--accent); 645 + font-weight: 500; 646 text-decoration: none; 647 } 648 649 + .support-link:hover { 650 text-decoration: underline; 651 } 652 653 @media (max-width: 480px) { 654 .total-amount { 655 + font-size: 2rem; 656 } 657 658 .audd-stats { 659 + grid-template-columns: 1fr 1fr; 660 } 661 662 + .day-label { 663 display: none; 664 } 665 }
+11 -1
scripts/costs/export_costs.py
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 - # fixed monthly costs (updated 2025-12-16) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 FIXED_COSTS = { 43 "fly_io": { 44 "breakdown": { ··· 59 "domain": 1.00, 60 "total": 1.16, 61 "note": "r2 egress is free, pages free tier", 62 }, 63 } 64 ··· 201 plyr_fly 202 + FIXED_COSTS["neon"]["total"] 203 + FIXED_COSTS["cloudflare"]["total"] 204 + audd_stats["estimated_cost"] 205 ) 206 ··· 225 "domain": FIXED_COSTS["cloudflare"]["domain"], 226 }, 227 "note": FIXED_COSTS["cloudflare"]["note"], 228 }, 229 "audd": { 230 "amount": audd_stats["estimated_cost"],
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 + # fixed monthly costs (updated 2025-12-26) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 + # upstash: free tier (256MB, 500K commands/month) 43 FIXED_COSTS = { 44 "fly_io": { 45 "breakdown": { ··· 60 "domain": 1.00, 61 "total": 1.16, 62 "note": "r2 egress is free, pages free tier", 63 + }, 64 + "upstash": { 65 + "total": 0.00, 66 + "note": "redis for docket + caching (free tier: 256MB, 500K commands/month)", 67 }, 68 } 69 ··· 206 plyr_fly 207 + FIXED_COSTS["neon"]["total"] 208 + FIXED_COSTS["cloudflare"]["total"] 209 + + FIXED_COSTS["upstash"]["total"] 210 + audd_stats["estimated_cost"] 211 ) 212 ··· 231 "domain": FIXED_COSTS["cloudflare"]["domain"], 232 }, 233 "note": FIXED_COSTS["cloudflare"]["note"], 234 + }, 235 + "upstash": { 236 + "amount": FIXED_COSTS["upstash"]["total"], 237 + "note": FIXED_COSTS["upstash"]["note"], 238 }, 239 "audd": { 240 "amount": audd_stats["estimated_cost"],