Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

show stacked bar of jetstream activity and jetstream logs, store 7 day history

+1873 -418
+1
.gitignore
··· 1 1 erl_crash.dump 2 2 .claude 3 + .playwright-mcp 3 4 4 5 # Database files 5 6 *.db
.playwright-mcp/page-2025-11-09T01-02-59-514Z.png

This is a binary file and will not be displayed.

.playwright-mcp/page-2025-11-09T01-05-31-489Z.png

This is a binary file and will not be displayed.

+1 -1
server/priv/static/styles.css
··· 1 1 /*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-300:oklch(87.1% .15 154.449);--color-green-500:oklch(72.3% .219 149.579);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--spacing:.25rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-10{height:calc(var(--spacing)*10)}.h-full{height:100%}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-10{width:calc(var(--spacing)*10)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.min-w-full{min-width:100%}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-zinc-800>:not(:last-child)){border-color:var(--color-zinc-800)}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-blue-800{border-color:var(--color-blue-800)}.border-green-800{border-color:var(--color-green-800)}.border-red-800{border-color:var(--color-red-800)}.border-red-900{border-color:var(--color-red-900)}.border-yellow-800{border-color:var(--color-yellow-800)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-800{border-color:var(--color-zinc-800)}.bg-blue-900\/30{background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-900\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950{background-color:var(--color-red-950)}.bg-yellow-900\/30{background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/30{background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-blue-300{color:var(--color-blue-300)}.text-green-300{color:var(--color-green-300)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-yellow-300{color:var(--color-yellow-300)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:border-zinc-600:hover{border-color:var(--color-zinc-600)}.hover\:bg-red-900\/30:hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/30:hover{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-zinc-600:focus{border-color:var(--color-zinc-600)}.focus\:border-zinc-700:focus{border-color:var(--color-zinc-700)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-zinc-800:disabled:hover{background-color:var(--color-zinc-800)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-purple-400:oklch(71.4% .203 305.504);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--spacing:.25rem;--container-2xl:42rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-6{margin-top:calc(var(--spacing)*6)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-10{height:calc(var(--spacing)*10)}.h-full{height:100%}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-96{max-height:calc(var(--spacing)*96)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing)*2)}.w-4{width:calc(var(--spacing)*4)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-20{width:calc(var(--spacing)*20)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-blue-800{border-color:var(--color-blue-800)}.border-green-800{border-color:var(--color-green-800)}.border-red-800{border-color:var(--color-red-800)}.border-red-900{border-color:var(--color-red-900)}.border-yellow-800{border-color:var(--color-yellow-800)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/50{border-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-blue-900\/30{background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-900\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950{background-color:var(--color-red-950)}.bg-yellow-900\/30{background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/30{background-color:color-mix(in oklab,var(--color-yellow-900)30%,transparent)}}.bg-zinc-500{background-color:var(--color-zinc-500)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-purple-400{color:var(--color-purple-400)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-yellow-300{color:var(--color-yellow-300)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:fill-zinc-700:is(:where(.group):hover *){fill:var(--color-zinc-700)}.group-hover\:text-zinc-400:is(:where(.group):hover *){color:var(--color-zinc-400)}.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}.hover\:border-zinc-600:hover{border-color:var(--color-zinc-600)}.hover\:bg-red-900\/30:hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/30:hover{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-700\/50:hover{background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-700\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.hover\:bg-zinc-900\/30:hover{background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-900\/30:hover{background-color:color-mix(in oklab,var(--color-zinc-900)30%,transparent)}}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\:no-underline:hover{text-decoration-line:none}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-zinc-600:focus{border-color:var(--color-zinc-600)}.focus\:border-zinc-700:focus{border-color:var(--color-zinc-700)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover){.disabled\:hover\:bg-zinc-800:disabled:hover{background-color:var(--color-zinc-800)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
+68
server/src/activity_cleanup.gleam
··· 1 + import gleam/erlang/process 2 + import gleam/otp/actor 3 + import gleam/string 4 + import jetstream_activity 5 + import logging 6 + import sqlight 7 + 8 + /// Message types for the cleanup actor 9 + pub type Message { 10 + Cleanup 11 + Shutdown 12 + } 13 + 14 + type State { 15 + State(db: sqlight.Connection, self: process.Subject(Message)) 16 + } 17 + 18 + /// Start the cleanup scheduler 19 + /// Returns a Subject that can be used to send messages to the scheduler 20 + pub fn start( 21 + db: sqlight.Connection, 22 + ) -> Result(process.Subject(Message), actor.StartError) { 23 + let initial_state = State(db: db, self: process.new_subject()) 24 + 25 + let result = 26 + actor.new(initial_state) 27 + |> actor.on_message(handle_message) 28 + |> actor.start 29 + 30 + // Schedule first cleanup after 1 hour 31 + case result { 32 + Ok(started) -> { 33 + let _ = process.send_after(started.data, 3_600_000, Cleanup) 34 + Ok(started.data) 35 + } 36 + Error(reason) -> Error(reason) 37 + } 38 + } 39 + 40 + fn handle_message( 41 + state: State, 42 + message: Message, 43 + ) -> actor.Next(State, Message) { 44 + case message { 45 + Cleanup -> { 46 + // Clean up activity entries older than 7 days (168 hours) 47 + case jetstream_activity.cleanup_old_activity(state.db, 168) { 48 + Ok(_) -> Nil 49 + Error(err) -> { 50 + logging.log( 51 + logging.Error, 52 + "[cleanup] Failed to cleanup old activity: " 53 + <> string.inspect(err), 54 + ) 55 + } 56 + } 57 + 58 + // Schedule next cleanup in 1 hour (3600000 milliseconds) 59 + let _ = process.send_after(state.self, 3_600_000, Cleanup) 60 + 61 + actor.continue(state) 62 + } 63 + Shutdown -> { 64 + logging.log(logging.Info, "[cleanup] Shutting down cleanup scheduler") 65 + actor.stop() 66 + } 67 + } 68 + }
+337
server/src/components/activity_chart.gleam
··· 1 + import gleam/erlang/process 2 + import gleam/float 3 + import gleam/int 4 + import gleam/list 5 + import gleam/result 6 + import jetstream_activity.{type ActivityBucket} 7 + import lustre 8 + import lustre/attribute 9 + import lustre/effect 10 + import lustre/element.{type Element} 11 + import lustre/element/html 12 + import lustre/element/svg 13 + import lustre/event 14 + import sqlight 15 + import stats_pubsub 16 + 17 + // APP 18 + 19 + pub fn component(db: sqlight.Connection) { 20 + lustre.application(init(db, _), update, view) 21 + } 22 + 23 + // MODEL 24 + 25 + pub type TimeRange { 26 + OneHour 27 + ThreeHour 28 + SixHour 29 + OneDay 30 + SevenDay 31 + } 32 + 33 + pub type Model { 34 + Model(db: sqlight.Connection, range: TimeRange, data: List(ActivityBucket)) 35 + } 36 + 37 + fn init(db: sqlight.Connection, _flags: Nil) -> #(Model, effect.Effect(Msg)) { 38 + // Default to 1 day 39 + let data = jetstream_activity.get_activity_1day(db) |> result.unwrap([]) 40 + #(Model(db: db, range: OneDay, data: data), start_listening_in_background()) 41 + } 42 + 43 + // UPDATE 44 + 45 + pub opaque type Msg { 46 + ChangeRange(TimeRange) 47 + StatsEventReceived(stats_pubsub.StatsEvent) 48 + } 49 + 50 + fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 51 + case msg { 52 + ChangeRange(range) -> { 53 + let data = case range { 54 + OneHour -> jetstream_activity.get_activity_1hr(model.db) 55 + ThreeHour -> jetstream_activity.get_activity_3hr(model.db) 56 + SixHour -> jetstream_activity.get_activity_6hr(model.db) 57 + OneDay -> jetstream_activity.get_activity_1day(model.db) 58 + SevenDay -> jetstream_activity.get_activity_7day(model.db) 59 + } 60 + |> result.unwrap([]) 61 + 62 + #(Model(..model, range: range, data: data), effect.none()) 63 + } 64 + 65 + StatsEventReceived(event) -> { 66 + case event { 67 + // When activity is logged or records are created/deleted, refresh the chart data 68 + stats_pubsub.ActivityLogged(_, _, _, _, _, _, _, _) 69 + | stats_pubsub.RecordCreated 70 + | stats_pubsub.RecordDeleted -> { 71 + // Refresh data for current time range 72 + let data = case model.range { 73 + OneHour -> jetstream_activity.get_activity_1hr(model.db) 74 + ThreeHour -> jetstream_activity.get_activity_3hr(model.db) 75 + SixHour -> jetstream_activity.get_activity_6hr(model.db) 76 + OneDay -> jetstream_activity.get_activity_1day(model.db) 77 + SevenDay -> jetstream_activity.get_activity_7day(model.db) 78 + } 79 + |> result.unwrap([]) 80 + 81 + #(Model(..model, data: data), effect.none()) 82 + } 83 + // Ignore other events 84 + _ -> #(model, effect.none()) 85 + } 86 + } 87 + } 88 + } 89 + 90 + // VIEW 91 + 92 + /// Render static chart for server-side pre-rendering 93 + pub fn render_static(data: List(ActivityBucket), range: TimeRange) -> Element(msg) { 94 + html.div([attribute.class("bg-zinc-800/50 rounded p-4 font-mono")], [ 95 + render_time_range_buttons_static(range), 96 + render_chart(data, range), 97 + ]) 98 + } 99 + 100 + fn view(model: Model) -> Element(Msg) { 101 + html.div([], [ 102 + // Include Tailwind styles in the Shadow DOM 103 + element.element( 104 + "link", 105 + [ 106 + attribute.attribute("rel", "stylesheet"), 107 + attribute.attribute("href", "/styles.css"), 108 + ], 109 + [], 110 + ), 111 + html.div([attribute.class("bg-zinc-800/50 rounded p-4 font-mono")], [ 112 + render_time_range_buttons(model.range), 113 + render_chart(model.data, model.range), 114 + ]), 115 + ]) 116 + } 117 + 118 + fn get_button_class(current_range: TimeRange, range: TimeRange) -> String { 119 + let base = "px-3 py-1 text-xs rounded transition-colors" 120 + case range == current_range { 121 + True -> base <> " bg-zinc-700 text-zinc-100" 122 + False -> base <> " bg-zinc-800/50 text-zinc-400 hover:bg-zinc-700/50 hover:text-zinc-300" 123 + } 124 + } 125 + 126 + fn render_time_range_buttons(current_range: TimeRange) -> Element(Msg) { 127 + let button = fn(range: TimeRange, label: String) { 128 + html.button( 129 + [ 130 + attribute.class(get_button_class(current_range, range)), 131 + event.on_click(ChangeRange(range)), 132 + ], 133 + [element.text(label)], 134 + ) 135 + } 136 + 137 + html.div([attribute.class("flex gap-2 mb-4")], [ 138 + button(OneHour, "1hr"), 139 + button(ThreeHour, "3hr"), 140 + button(SixHour, "6hr"), 141 + button(OneDay, "1 day"), 142 + button(SevenDay, "7 day"), 143 + ]) 144 + } 145 + 146 + fn render_time_range_buttons_static(current_range: TimeRange) -> Element(msg) { 147 + let button = fn(range: TimeRange, label: String) { 148 + html.button( 149 + [attribute.class(get_button_class(current_range, range))], 150 + [element.text(label)], 151 + ) 152 + } 153 + 154 + html.div([attribute.class("flex gap-2 mb-4")], [ 155 + button(OneHour, "1hr"), 156 + button(ThreeHour, "3hr"), 157 + button(SixHour, "6hr"), 158 + button(OneDay, "1 day"), 159 + button(SevenDay, "7 day"), 160 + ]) 161 + } 162 + 163 + fn render_chart(data: List(ActivityBucket), range: TimeRange) -> Element(msg) { 164 + case data { 165 + [] -> { 166 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 167 + element.text("No activity data available"), 168 + ]) 169 + } 170 + buckets -> { 171 + let max_value = calculate_max_value(buckets) 172 + let #(bar_width, gap) = case range { 173 + SevenDay -> #(160.0, 12.0) 174 + _ -> #(30.0, 4.0) 175 + } 176 + let num_buckets = list.length(buckets) 177 + // Width = (num_bars * bar_width) + ((num_bars - 1) * gap) 178 + let chart_width = int.to_float(num_buckets) *. bar_width +. int.to_float(num_buckets - 1) *. gap 179 + let chart_height = 120.0 180 + 181 + html.div([attribute.class("w-full")], [ 182 + svg.svg( 183 + [ 184 + attribute.attribute("viewBox", "0 0 " <> float.to_string(chart_width) <> " " <> float.to_string(chart_height)), 185 + attribute.attribute("width", "100%"), 186 + attribute.attribute("height", float.to_string(chart_height)), 187 + attribute.attribute("style", "min-height: 120px"), 188 + attribute.attribute("preserveAspectRatio", "none"), 189 + ], 190 + list.index_map(buckets, fn(bucket, index) { 191 + render_stacked_bar(bucket, index, bar_width, gap, chart_height, max_value) 192 + }), 193 + ), 194 + ]) 195 + } 196 + } 197 + } 198 + 199 + fn calculate_max_value(buckets: List(ActivityBucket)) -> Int { 200 + buckets 201 + |> list.map(fn(b) { b.create_count + b.update_count + b.delete_count }) 202 + |> list.reduce(int.max) 203 + |> result.unwrap(1) 204 + } 205 + 206 + fn render_stacked_bar( 207 + bucket: ActivityBucket, 208 + index: Int, 209 + bar_width: Float, 210 + gap: Float, 211 + chart_height: Float, 212 + max_value: Int, 213 + ) -> Element(msg) { 214 + let x = int.to_float(index) *. { bar_width +. gap } 215 + let total = bucket.create_count + bucket.update_count + bucket.delete_count 216 + 217 + case total { 218 + 0 -> { 219 + // Render placeholder bar for empty bins 220 + let placeholder_height = 4.0 221 + let placeholder_y = chart_height -. placeholder_height 222 + svg.g( 223 + [ 224 + attribute.class("group"), 225 + attribute.attribute("data-tooltip-timestamp", bucket.timestamp), 226 + attribute.attribute("data-create", "0"), 227 + attribute.attribute("data-update", "0"), 228 + attribute.attribute("data-delete", "0"), 229 + ], 230 + [ 231 + svg.rect([ 232 + attribute.attribute("x", float.to_string(x)), 233 + attribute.attribute("y", float.to_string(placeholder_y)), 234 + attribute.attribute("width", float.to_string(bar_width)), 235 + attribute.attribute("height", float.to_string(placeholder_height)), 236 + attribute.attribute("style", "fill: #3f3f46 !important; stroke: none; display: inline; cursor: pointer"), 237 + attribute.class("group-hover:fill-zinc-700"), 238 + ]) 239 + ], 240 + ) 241 + } 242 + _ -> { 243 + let scale = chart_height /. int.to_float(max_value) 244 + 245 + // Calculate heights for each segment 246 + let delete_height = int.to_float(bucket.delete_count) *. scale 247 + let update_height = int.to_float(bucket.update_count) *. scale 248 + let create_height = int.to_float(bucket.create_count) *. scale 249 + 250 + // Calculate y positions (bottom to top: delete, update, create) 251 + let delete_y = chart_height -. delete_height 252 + let update_y = delete_y -. update_height 253 + let create_y = update_y -. create_height 254 + 255 + svg.g( 256 + [ 257 + attribute.class("group"), 258 + attribute.attribute("data-tooltip-timestamp", bucket.timestamp), 259 + attribute.attribute("data-create", int.to_string(bucket.create_count)), 260 + attribute.attribute("data-update", int.to_string(bucket.update_count)), 261 + attribute.attribute("data-delete", int.to_string(bucket.delete_count)), 262 + ], 263 + [ 264 + // Delete segment (red) - bottom 265 + case bucket.delete_count > 0 { 266 + True -> 267 + svg.rect([ 268 + attribute.attribute("x", float.to_string(x)), 269 + attribute.attribute("y", float.to_string(delete_y)), 270 + attribute.attribute("width", float.to_string(bar_width)), 271 + attribute.attribute("height", float.to_string(delete_height)), 272 + attribute.attribute("style", "fill: #ef4444 !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 273 + attribute.class("group-hover:opacity-80"), 274 + ]) 275 + False -> element.none() 276 + }, 277 + // Update segment (blue) - middle 278 + case bucket.update_count > 0 { 279 + True -> 280 + svg.rect([ 281 + attribute.attribute("x", float.to_string(x)), 282 + attribute.attribute("y", float.to_string(update_y)), 283 + attribute.attribute("width", float.to_string(bar_width)), 284 + attribute.attribute("height", float.to_string(update_height)), 285 + attribute.attribute("style", "fill: #60a5fa !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 286 + attribute.class("group-hover:opacity-80"), 287 + ]) 288 + False -> element.none() 289 + }, 290 + // Create segment (green) - top 291 + case bucket.create_count > 0 { 292 + True -> 293 + svg.rect([ 294 + attribute.attribute("x", float.to_string(x)), 295 + attribute.attribute("y", float.to_string(create_y)), 296 + attribute.attribute("width", float.to_string(bar_width)), 297 + attribute.attribute("height", float.to_string(create_height)), 298 + attribute.attribute("style", "fill: #22c55e !important; stroke: none; display: inline; cursor: pointer; transition: opacity 0.2s"), 299 + attribute.class("group-hover:opacity-80"), 300 + ]) 301 + False -> element.none() 302 + }, 303 + ], 304 + ) 305 + } 306 + } 307 + } 308 + 309 + 310 + // EFFECTS 311 + 312 + fn start_listening_in_background() -> effect.Effect(Msg) { 313 + use dispatch <- effect.from 314 + 315 + // Spawn a single long-running process to listen for stats events 316 + let _ = 317 + process.spawn_unlinked(fn() { 318 + // Subscribe in THIS process, not the component process 319 + let subscriber = stats_pubsub.subscribe() 320 + listen_loop(subscriber, dispatch) 321 + }) 322 + 323 + Nil 324 + } 325 + 326 + fn listen_loop( 327 + subscriber: process.Subject(stats_pubsub.StatsEvent), 328 + dispatch: fn(Msg) -> Nil, 329 + ) -> Nil { 330 + let selector = process.new_selector() |> process.select(subscriber) 331 + 332 + let event = process.selector_receive_forever(selector) 333 + dispatch(StatsEventReceived(event)) 334 + // Keep listening 335 + listen_loop(subscriber, dispatch) 336 + } 337 +
-102
server/src/components/collection_table.gleam
··· 1 - import database 2 - import format 3 - import gleam/list 4 - import lustre/attribute 5 - import lustre/element.{type Element} 6 - import lustre/element/html 7 - 8 - /// Renders a table of collections with their record counts 9 - pub fn view( 10 - collection_stats: List(database.CollectionStat), 11 - record_lexicons: List(database.Lexicon), 12 - ) -> Element(msg) { 13 - let rows = build_rows(collection_stats, record_lexicons) 14 - 15 - html.div( 16 - [ 17 - attribute.class( 18 - "bg-zinc-900 rounded-lg shadow-sm border border-zinc-800 overflow-hidden", 19 - ), 20 - ], 21 - [ 22 - html.table([attribute.class("min-w-full divide-y divide-zinc-800")], [ 23 - render_header(), 24 - html.tbody( 25 - [attribute.class("bg-zinc-900 divide-y divide-zinc-800")], 26 - rows, 27 - ), 28 - ]), 29 - ], 30 - ) 31 - } 32 - 33 - /// Render the table header 34 - fn render_header() -> Element(msg) { 35 - html.thead([attribute.class("bg-zinc-900")], [ 36 - html.tr([], [ 37 - html.th( 38 - [ 39 - attribute.class( 40 - "px-4 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider", 41 - ), 42 - ], 43 - [element.text("Collection")], 44 - ), 45 - html.th( 46 - [ 47 - attribute.class( 48 - "px-4 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider", 49 - ), 50 - ], 51 - [element.text("Record Count")], 52 - ), 53 - ]), 54 - ]) 55 - } 56 - 57 - /// Build all table rows from collection stats and lexicons 58 - fn build_rows( 59 - collection_stats: List(database.CollectionStat), 60 - record_lexicons: List(database.Lexicon), 61 - ) -> List(Element(msg)) { 62 - // Build rows from actual records 63 - let record_rows = 64 - collection_stats 65 - |> list.map(fn(stat) { render_stat_row(stat.collection, stat.count) }) 66 - 67 - // Build rows for lexicons without records yet 68 - let lexicon_rows = 69 - record_lexicons 70 - |> list.filter(fn(lexicon) { 71 - // Only show lexicons that don't already appear in collection_stats 72 - !list.any(collection_stats, fn(stat) { stat.collection == lexicon.id }) 73 - }) 74 - |> list.map(fn(lexicon) { render_empty_row(lexicon.id) }) 75 - 76 - // Combine both types of rows 77 - list.append(record_rows, lexicon_rows) 78 - } 79 - 80 - /// Render a row for a collection with records 81 - fn render_stat_row(collection: String, count: Int) -> Element(msg) { 82 - html.tr([attribute.class("hover:bg-zinc-800 transition-colors")], [ 83 - html.td([attribute.class("px-4 py-3 text-sm text-zinc-200")], [ 84 - element.text(collection), 85 - ]), 86 - html.td([attribute.class("px-4 py-3 text-sm text-zinc-300")], [ 87 - element.text(format.format_number(count)), 88 - ]), 89 - ]) 90 - } 91 - 92 - /// Render a row for a lexicon without records yet 93 - fn render_empty_row(collection: String) -> Element(msg) { 94 - html.tr([attribute.class("hover:bg-zinc-800 transition-colors")], [ 95 - html.td([attribute.class("px-4 py-3 text-sm text-zinc-200")], [ 96 - element.text(collection), 97 - ]), 98 - html.td([attribute.class("px-4 py-3 text-sm text-zinc-500 italic")], [ 99 - element.text("0"), 100 - ]), 101 - ]) 102 - }
+331
server/src/components/jetstream_activity_log.gleam
··· 1 + import gleam/erlang/process 2 + import gleam/int 3 + import gleam/list 4 + import gleam/option 5 + import gleam/result 6 + import gleam/string 7 + import jetstream_activity.{type ActivityEntry} 8 + import lustre 9 + import lustre/attribute 10 + import lustre/effect 11 + import lustre/element.{type Element} 12 + import lustre/element/html 13 + import sqlight 14 + import stats_pubsub 15 + 16 + // APP 17 + 18 + pub fn component(db: sqlight.Connection) { 19 + lustre.application(init(db, _), update, view) 20 + } 21 + 22 + // MODEL 23 + 24 + pub type Model { 25 + Model( 26 + db: sqlight.Connection, 27 + activities: List(ActivityEntry), 28 + stats_subscriber: process.Subject(stats_pubsub.StatsEvent), 29 + ) 30 + } 31 + 32 + fn init(db: sqlight.Connection, _flags: Nil) -> #(Model, effect.Effect(Msg)) { 33 + // Get initial activity from database (last 24 hours) 34 + let activities = 35 + jetstream_activity.get_recent_activity(db, 24) |> result.unwrap([]) 36 + 37 + // We'll subscribe in the listener process, so create a dummy subject here 38 + let dummy_subscriber = process.new_subject() 39 + 40 + #( 41 + Model(db: db, activities: activities, stats_subscriber: dummy_subscriber), 42 + start_listening_in_background(), 43 + ) 44 + } 45 + 46 + // UPDATE 47 + 48 + pub opaque type Msg { 49 + StatsEventReceived(stats_pubsub.StatsEvent) 50 + } 51 + 52 + fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 53 + case msg { 54 + StatsEventReceived(event) -> { 55 + case event { 56 + stats_pubsub.ActivityLogged( 57 + id, 58 + timestamp, 59 + operation, 60 + collection, 61 + did, 62 + status, 63 + error_message, 64 + event_json, 65 + ) -> { 66 + // Add new activity to the beginning of the list 67 + let new_activity = 68 + jetstream_activity.ActivityEntry( 69 + id: id, 70 + timestamp: timestamp, 71 + operation: operation, 72 + collection: collection, 73 + did: did, 74 + status: status, 75 + error_message: error_message, 76 + event_json: event_json, 77 + ) 78 + 79 + // Prepend new activity and limit to 100 entries for UI performance 80 + let updated_activities = 81 + [new_activity, ..model.activities] |> list.take(100) 82 + 83 + #(Model(..model, activities: updated_activities), effect.none()) 84 + } 85 + // Ignore other stats events 86 + _ -> #(model, effect.none()) 87 + } 88 + } 89 + } 90 + } 91 + 92 + // EFFECTS 93 + 94 + fn start_listening_in_background() -> effect.Effect(Msg) { 95 + use dispatch <- effect.from 96 + 97 + // Spawn a single long-running process to listen for stats events 98 + let _ = 99 + process.spawn_unlinked(fn() { 100 + // Subscribe in THIS process, not the component process 101 + let subscriber = stats_pubsub.subscribe() 102 + listen_loop(subscriber, dispatch) 103 + }) 104 + 105 + Nil 106 + } 107 + 108 + fn listen_loop( 109 + subscriber: process.Subject(stats_pubsub.StatsEvent), 110 + dispatch: fn(Msg) -> Nil, 111 + ) -> Nil { 112 + let selector = process.new_selector() |> process.select(subscriber) 113 + 114 + let event = process.selector_receive_forever(selector) 115 + dispatch(StatsEventReceived(event)) 116 + // Keep listening 117 + listen_loop(subscriber, dispatch) 118 + } 119 + 120 + // VIEW 121 + 122 + /// Render static activity log for initial page load (before WebSocket connects) 123 + pub fn render_static(activities: List(ActivityEntry)) -> Element(msg) { 124 + render_activity_log(activities, True) 125 + } 126 + 127 + /// Shared activity log renderer 128 + fn render_activity_log(activities: List(ActivityEntry), static: Bool) -> Element(msg) { 129 + html.div([attribute.class("font-mono mb-8")], [ 130 + html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 131 + // Header 132 + html.div( 133 + [attribute.class("flex items-center justify-between mb-3")], 134 + [ 135 + html.div([attribute.class("text-sm text-zinc-500")], [ 136 + element.text("JetStream Activity"), 137 + ]), 138 + html.span([attribute.class("text-xs text-zinc-600")], [ 139 + element.text(int.to_string(list.length(activities)) <> " events (24h)"), 140 + ]), 141 + ], 142 + ), 143 + // Activity list - scrollable 144 + html.div( 145 + [attribute.class("max-h-80 overflow-y-auto")], 146 + [ 147 + case activities { 148 + [] -> 149 + html.div([attribute.class("py-8 text-center text-zinc-600 text-xs")], [ 150 + element.text("No activity in the last 24 hours"), 151 + ]) 152 + activities -> html.div([], list.map(activities, render_activity_entry(_, static))) 153 + }, 154 + ], 155 + ), 156 + ]), 157 + ]) 158 + } 159 + 160 + /// Renders a single activity entry (compact Grafana-style with expandable details) 161 + fn render_activity_entry(entry: ActivityEntry, static: Bool) -> Element(msg) { 162 + let status_color = case entry.status { 163 + "success" -> "text-green-500" 164 + "validation_error" -> "text-yellow-500" 165 + "error" -> "text-red-500" 166 + "processing" -> "text-blue-500" 167 + _ -> "text-zinc-500" 168 + } 169 + 170 + let status_icon = case entry.status { 171 + "success" -> "✓" 172 + "validation_error" -> "⚠" 173 + "error" -> "✗" 174 + "processing" -> "⋯" 175 + _ -> "•" 176 + } 177 + 178 + let operation_color = case entry.operation { 179 + "create" -> "text-green-400" 180 + "update" -> "text-blue-400" 181 + "delete" -> "text-red-400" 182 + _ -> "text-zinc-400" 183 + } 184 + 185 + let entry_id = "activity-" <> int.to_string(entry.id) 186 + 187 + html.div( 188 + [ 189 + attribute.class("border-l-2 border-zinc-700/50 hover:border-zinc-600 transition-colors"), 190 + attribute.attribute("data-entry-id", entry_id), 191 + ], 192 + [ 193 + // Main log line 194 + html.div( 195 + [ 196 + attribute.class("flex items-start gap-2 py-1 text-xs font-mono hover:bg-zinc-900/30 cursor-pointer group"), 197 + attribute.attribute("onclick", "this.parentElement.classList.toggle('expanded')"), 198 + ], 199 + [ 200 + // Caret for expansion (always visible) 201 + html.span( 202 + [ 203 + attribute.class("text-zinc-600 group-hover:text-zinc-400 shrink-0 select-none transition-transform caret"), 204 + attribute.attribute("data-caret", ""), 205 + ], 206 + [element.text("›")], 207 + ), 208 + // Timestamp - formatted server-side for static, client-side for dynamic 209 + html.span( 210 + [ 211 + attribute.class("text-zinc-600 shrink-0 w-16"), 212 + attribute.attribute("data-timestamp", entry.timestamp), 213 + ], 214 + [element.text(case static { 215 + True -> format_time_only(entry.timestamp) 216 + False -> entry.timestamp 217 + })], 218 + ), 219 + // Status icon 220 + html.span([attribute.class(status_color <> " shrink-0 w-4")], [ 221 + element.text(status_icon), 222 + ]), 223 + // Operation 224 + html.span([attribute.class(operation_color <> " shrink-0 w-12")], [ 225 + element.text(entry.operation), 226 + ]), 227 + // Collection 228 + html.span([attribute.class("text-purple-400 shrink-0")], [ 229 + element.text(entry.collection), 230 + ]), 231 + // DID 232 + html.span([attribute.class("text-zinc-500 truncate")], [ 233 + element.text(entry.did), 234 + ]), 235 + ], 236 + ), 237 + // Expanded details section - shows all fields and JSON snippet 238 + html.div( 239 + [ 240 + attribute.class("px-6 py-2 text-xs bg-zinc-900/50 border-t border-zinc-800 hidden space-y-1"), 241 + attribute.attribute("data-details", ""), 242 + ], 243 + [ 244 + // Full timestamp 245 + html.div([attribute.class("flex gap-2")], [ 246 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Timestamp:")]), 247 + html.span([attribute.class("text-zinc-400")], [element.text(entry.timestamp)]), 248 + ]), 249 + // Full DID 250 + html.div([attribute.class("flex gap-2")], [ 251 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("DID:")]), 252 + html.span([attribute.class("text-zinc-400 font-mono break-all")], [element.text(entry.did)]), 253 + ]), 254 + // Status 255 + html.div([attribute.class("flex gap-2")], [ 256 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Status:")]), 257 + html.span([attribute.class(case entry.status { 258 + "success" -> "text-green-400" 259 + "validation_error" -> "text-yellow-400" 260 + "error" -> "text-red-400" 261 + _ -> "text-zinc-400" 262 + })], [element.text(entry.status)]), 263 + ]), 264 + // Error message (if present) 265 + case entry.error_message { 266 + option.Some(err_msg) -> 267 + html.div([attribute.class("flex gap-2")], [ 268 + html.span([attribute.class("text-zinc-600 w-20")], [element.text("Error:")]), 269 + html.span([attribute.class("text-red-400")], [element.text(err_msg)]), 270 + ]) 271 + option.None -> element.none() 272 + }, 273 + // JSON snippet - will be formatted client-side 274 + html.div([attribute.class("mt-2")], [ 275 + html.div([attribute.class("text-zinc-600 mb-1")], [element.text("Event JSON:")]), 276 + html.pre( 277 + [ 278 + attribute.class("text-zinc-400 bg-black/40 p-2 rounded text-[10px] whitespace-pre-wrap block"), 279 + attribute.attribute("data-json", entry.event_json), 280 + ], 281 + [element.text(entry.event_json)], 282 + ), 283 + ]), 284 + ], 285 + ), 286 + ], 287 + ) 288 + } 289 + 290 + /// Extract time portion from ISO8601 timestamp (HH:MM:SS) 291 + /// This matches the format used by the client-side JavaScript formatter 292 + fn format_time_only(timestamp: String) -> String { 293 + // ISO8601 format: 2025-11-09T02:01:54.375Z 294 + // We want to extract: 02:01:54 295 + case string.split(timestamp, "T") { 296 + [_, time_part] -> { 297 + // time_part is like "02:01:54.375Z" 298 + case string.split(time_part, ".") { 299 + [time, _] -> time // Returns "02:01:54" 300 + _ -> timestamp // Fallback to full timestamp 301 + } 302 + } 303 + _ -> timestamp // Fallback to full timestamp 304 + } 305 + } 306 + 307 + fn view(model: Model) -> Element(Msg) { 308 + html.div([attribute.class("font-mono")], [ 309 + // Include Tailwind styles in the Shadow DOM 310 + element.element( 311 + "link", 312 + [ 313 + attribute.attribute("rel", "stylesheet"), 314 + attribute.attribute("href", "/styles.css"), 315 + ], 316 + [], 317 + ), 318 + // CSS for expandable details 319 + element.element( 320 + "style", 321 + [], 322 + [ 323 + element.text( 324 + "[data-entry-id].expanded [data-caret] { transform: rotate(90deg); } 325 + [data-entry-id].expanded [data-details] { display: block !important; }", 326 + ), 327 + ], 328 + ), 329 + render_activity_log(model.activities, False), 330 + ]) 331 + }
+177 -3
server/src/components/layout.gleam
··· 57 57 ], 58 58 [], 59 59 ), 60 + // Tippy.js for tooltips 61 + html.script( 62 + [attribute.attribute("src", "https://unpkg.com/@popperjs/core@2")], 63 + "", 64 + ), 65 + html.script( 66 + [attribute.attribute("src", "https://unpkg.com/tippy.js@6")], 67 + "", 68 + ), 69 + element.element( 70 + "link", 71 + [ 72 + attribute.attribute("rel", "stylesheet"), 73 + attribute.attribute("href", "https://unpkg.com/tippy.js@6/themes/light.css"), 74 + ], 75 + [], 76 + ), 60 77 // Lustre server component runtime 61 78 html.script( 62 79 [ ··· 65 82 ], 66 83 "", 67 84 ), 68 - // Listen for backfill-complete event and reload page 85 + // Define custom elements for client-side formatting 69 86 html.script( 70 87 [], 71 88 " 72 - // Wait for DOM to be ready 89 + // Format timestamps inside Shadow DOM 90 + function formatTimestamps(shadowRoot) { 91 + if (!shadowRoot) return; 92 + 93 + const timeElements = shadowRoot.querySelectorAll('[data-timestamp]'); 94 + timeElements.forEach(el => { 95 + const utcTime = el.getAttribute('data-timestamp'); 96 + if (utcTime) { 97 + try { 98 + const date = new Date(utcTime); 99 + const formatted = date.toLocaleTimeString([], { 100 + hour: '2-digit', 101 + minute: '2-digit', 102 + second: '2-digit', 103 + hour12: false 104 + }); 105 + // Always reformat - Lustre may reuse elements with different data 106 + if (el.textContent !== formatted) { 107 + el.textContent = formatted; 108 + } 109 + } catch (e) { 110 + console.error('[Timestamp Formatter] Error:', e); 111 + } 112 + } 113 + }); 114 + 115 + // Format JSON elements 116 + const jsonElements = shadowRoot.querySelectorAll('[data-json]'); 117 + jsonElements.forEach(el => { 118 + const jsonStr = el.getAttribute('data-json'); 119 + if (jsonStr) { 120 + try { 121 + const parsed = JSON.parse(jsonStr); 122 + if (parsed.commit && typeof parsed.commit.record === 'string') { 123 + try { 124 + parsed.commit.record = JSON.parse(parsed.commit.record); 125 + } catch (e) {} 126 + } 127 + const formatted = JSON.stringify(parsed, null, 2); 128 + if (el.textContent !== formatted) { 129 + el.textContent = formatted; 130 + } 131 + } catch (e) { 132 + console.error('[JSON Formatter] Error:', e); 133 + } 134 + } 135 + }); 136 + } 137 + 138 + // Listen for activity log component mount and updates 73 139 document.addEventListener('DOMContentLoaded', function() { 140 + const activityLog = document.querySelector('lustre-server-component#activity-log'); 141 + 142 + if (activityLog) { 143 + // Format on initial mount 144 + activityLog.addEventListener('lustre:mount', function() { 145 + formatTimestamps(activityLog.shadowRoot); 146 + }); 147 + 148 + // Also try formatting immediately in case already mounted 149 + if (activityLog.shadowRoot) { 150 + formatTimestamps(activityLog.shadowRoot); 151 + } 152 + 153 + // Watch for changes in Shadow DOM using MutationObserver 154 + const observer = new MutationObserver(() => { 155 + formatTimestamps(activityLog.shadowRoot); 156 + }); 157 + 158 + // Wait a bit for shadow root to be available 159 + setTimeout(() => { 160 + if (activityLog.shadowRoot) { 161 + observer.observe(activityLog.shadowRoot, { 162 + childList: true, 163 + subtree: true 164 + }); 165 + // Format once more to catch anything that loaded during timeout 166 + formatTimestamps(activityLog.shadowRoot); 167 + } 168 + }, 100); 169 + } 170 + 171 + // Initialize tooltips for activity chart 172 + function initChartTooltips(shadowRoot) { 173 + if (!shadowRoot || !window.tippy) return; 174 + 175 + const bars = shadowRoot.querySelectorAll('[data-tooltip-timestamp]'); 176 + bars.forEach(bar => { 177 + // Destroy existing tippy instance if it exists 178 + if (bar._tippy) { 179 + bar._tippy.destroy(); 180 + } 181 + 182 + const timestamp = bar.getAttribute('data-tooltip-timestamp'); 183 + const create = bar.getAttribute('data-create') || '0'; 184 + const update = bar.getAttribute('data-update') || '0'; 185 + const del = bar.getAttribute('data-delete') || '0'; 186 + const total = parseInt(create) + parseInt(update) + parseInt(del); 187 + 188 + if (!timestamp) return; 189 + 190 + try { 191 + const date = new Date(timestamp); 192 + const formatted = date.toLocaleString([], { 193 + month: 'short', 194 + day: 'numeric', 195 + hour: 'numeric', 196 + minute: '2-digit', 197 + hour12: true 198 + }); 199 + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 200 + 201 + let content = formatted + ' (' + timezone + ')'; 202 + if (total === 0) { 203 + content += '\\nNo activity'; 204 + } else { 205 + content += '\\nCreate: ' + create; 206 + content += '\\nUpdate: ' + update; 207 + content += '\\nDelete: ' + del; 208 + content += '\\nTotal: ' + total; 209 + } 210 + 211 + tippy(bar, { 212 + content: content.replace(/\\n/g, '<br>'), 213 + allowHTML: true, 214 + theme: 'dark', 215 + placement: 'top' 216 + }); 217 + } catch (e) { 218 + console.error('[Tooltip] Error:', e); 219 + } 220 + }); 221 + } 222 + 223 + const activityChart = document.querySelector('lustre-server-component#activity-chart'); 224 + if (activityChart) { 225 + activityChart.addEventListener('lustre:mount', function() { 226 + initChartTooltips(activityChart.shadowRoot); 227 + }); 228 + 229 + if (activityChart.shadowRoot) { 230 + initChartTooltips(activityChart.shadowRoot); 231 + } 232 + 233 + const chartObserver = new MutationObserver(() => { 234 + initChartTooltips(activityChart.shadowRoot); 235 + }); 236 + 237 + setTimeout(() => { 238 + if (activityChart.shadowRoot) { 239 + chartObserver.observe(activityChart.shadowRoot, { 240 + childList: true, 241 + subtree: true 242 + }); 243 + initChartTooltips(activityChart.shadowRoot); 244 + } 245 + }, 100); 246 + } 247 + 248 + // Listen for backfill-complete event and reload page 74 249 const backfillButton = document.querySelector('lustre-server-component#backfill-button'); 75 250 if (backfillButton) { 76 251 backfillButton.addEventListener('backfill-complete', function() { 77 - // Reload page to show updated database stats 78 252 window.location.reload(); 79 253 }); 80 254 }
-142
server/src/components/sparkline.gleam
··· 1 - import database 2 - import gleam/float 3 - import gleam/int 4 - import gleam/list 5 - import gleam/string 6 - import lustre/attribute 7 - import lustre/element.{type Element} 8 - 9 - /// Renders a sparkline chart from activity data points 10 - pub fn view(activity: List(database.ActivityPoint)) -> Element(msg) { 11 - case activity { 12 - [] -> element.none() 13 - _ -> render_chart(activity) 14 - } 15 - } 16 - 17 - fn render_chart(data: List(database.ActivityPoint)) -> Element(msg) { 18 - let width = 800 19 - let height = 80 20 - 21 - // Calculate min/max for scaling 22 - let counts = list.map(data, fn(point) { point.count }) 23 - let max = case list.reduce(counts, int.max) { 24 - Ok(m) -> int.max(m, 1) 25 - Error(_) -> 1 26 - } 27 - let min = case list.reduce(counts, int.min) { 28 - Ok(m) -> int.min(m, 0) 29 - Error(_) -> 0 30 - } 31 - 32 - let range = max - min 33 - let range_float = case range { 34 - 0 -> 1.0 35 - _ -> int.to_float(range) 36 - } 37 - let max_height = int.to_float(height) *. 0.75 38 - 39 - // Generate polyline points 40 - let data_length = list.length(data) 41 - let points_string = 42 - data 43 - |> list.index_map(fn(point, index) { 44 - let x = case data_length { 45 - 1 -> int.to_float(width) /. 2.0 46 - _ -> 47 - int.to_float(index) 48 - /. int.to_float(data_length - 1) 49 - *. int.to_float(width) 50 - } 51 - let y_normalized = int.to_float(point.count - min) /. range_float 52 - let y = int.to_float(height) -. y_normalized *. max_height 53 - 54 - float.to_string(x) <> "," <> float.to_string(y) 55 - }) 56 - |> string.join(" ") 57 - 58 - // Generate area path for gradient fill 59 - let area_path = 60 - "M 0," 61 - <> int.to_string(height) 62 - <> " L " 63 - <> points_string 64 - <> " L " 65 - <> int.to_string(width) 66 - <> "," 67 - <> int.to_string(height) 68 - <> " Z" 69 - 70 - // Create SVG element 71 - element.element( 72 - "svg", 73 - [ 74 - attribute.attribute("width", int.to_string(width)), 75 - attribute.attribute("height", int.to_string(height)), 76 - attribute.class("w-full"), 77 - attribute.attribute( 78 - "viewBox", 79 - "0 0 " <> int.to_string(width) <> " " <> int.to_string(height), 80 - ), 81 - attribute.attribute("preserveAspectRatio", "none"), 82 - ], 83 - [ 84 - // Define gradient 85 - element.element("defs", [], [ 86 - element.element( 87 - "linearGradient", 88 - [ 89 - attribute.id("sparklineGradient"), 90 - attribute.attribute("x1", "0%"), 91 - attribute.attribute("y1", "0%"), 92 - attribute.attribute("x2", "0%"), 93 - attribute.attribute("y2", "100%"), 94 - ], 95 - [ 96 - element.element( 97 - "stop", 98 - [ 99 - attribute.attribute("offset", "0%"), 100 - attribute.attribute("stop-color", "#22d3ee"), 101 - attribute.attribute("stop-opacity", "0.5"), 102 - ], 103 - [], 104 - ), 105 - element.element( 106 - "stop", 107 - [ 108 - attribute.attribute("offset", "100%"), 109 - attribute.attribute("stop-color", "#22d3ee"), 110 - attribute.attribute("stop-opacity", "0.1"), 111 - ], 112 - [], 113 - ), 114 - ], 115 - ), 116 - ]), 117 - // Area fill 118 - element.element( 119 - "path", 120 - [ 121 - attribute.attribute("d", area_path), 122 - attribute.attribute("fill", "url(#sparklineGradient)"), 123 - attribute.attribute("stroke-width", "0"), 124 - ], 125 - [], 126 - ), 127 - // Line 128 - element.element( 129 - "polyline", 130 - [ 131 - attribute.attribute("points", points_string), 132 - attribute.attribute("fill", "none"), 133 - attribute.attribute("stroke", "#22d3ee"), 134 - attribute.attribute("stroke-width", "2"), 135 - attribute.attribute("stroke-linecap", "round"), 136 - attribute.attribute("stroke-linejoin", "round"), 137 - ], 138 - [], 139 - ), 140 - ], 141 - ) 142 - }
+2
server/src/components/stats_cards.gleam
··· 72 72 stats_pubsub.ActorCreated -> { 73 73 #(Model(..model, actor_count: model.actor_count + 1), effect.none()) 74 74 } 75 + // Ignore activity logged events - those are for the activity log component 76 + stats_pubsub.ActivityLogged(..) -> #(model, effect.none()) 75 77 } 76 78 } 77 79 }
+74 -103
server/src/database.gleam
··· 267 267 sqlight.exec(create_cid_index_sql, conn) 268 268 } 269 269 270 + /// Migration v4: Add jetstream_activity table for 24h activity log 271 + fn migration_v4(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 272 + logging.log(logging.Info, "Running migration v4 (jetstream_activity table)...") 273 + 274 + let create_table_sql = 275 + " 276 + CREATE TABLE IF NOT EXISTS jetstream_activity ( 277 + id INTEGER PRIMARY KEY AUTOINCREMENT, 278 + timestamp TEXT NOT NULL, 279 + operation TEXT NOT NULL, 280 + collection TEXT NOT NULL, 281 + did TEXT NOT NULL, 282 + status TEXT NOT NULL, 283 + error_message TEXT, 284 + event_json TEXT NOT NULL 285 + ) 286 + " 287 + 288 + let create_timestamp_index_sql = 289 + " 290 + CREATE INDEX IF NOT EXISTS idx_jetstream_activity_timestamp 291 + ON jetstream_activity(timestamp DESC) 292 + " 293 + 294 + use _ <- result.try(sqlight.exec(create_table_sql, conn)) 295 + sqlight.exec(create_timestamp_index_sql, conn) 296 + } 297 + 270 298 /// Runs all pending migrations based on current schema version 271 299 fn run_migrations(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 272 300 use current_version <- result.try(get_current_version(conn)) ··· 282 310 0 -> { 283 311 use _ <- result.try(apply_migration(conn, 1, migration_v1)) 284 312 use _ <- result.try(apply_migration(conn, 2, migration_v2)) 285 - apply_migration(conn, 3, migration_v3) 313 + use _ <- result.try(apply_migration(conn, 3, migration_v3)) 314 + apply_migration(conn, 4, migration_v4) 286 315 } 287 316 288 - // Run v2 and v3 migrations 317 + // Run v2, v3, and v4 migrations 289 318 1 -> { 290 319 use _ <- result.try(apply_migration(conn, 2, migration_v2)) 291 - apply_migration(conn, 3, migration_v3) 320 + use _ <- result.try(apply_migration(conn, 3, migration_v3)) 321 + apply_migration(conn, 4, migration_v4) 292 322 } 293 323 294 - // Run v3 migration 295 - 2 -> apply_migration(conn, 3, migration_v3) 324 + // Run v3 and v4 migrations 325 + 2 -> { 326 + use _ <- result.try(apply_migration(conn, 3, migration_v3)) 327 + apply_migration(conn, 4, migration_v4) 328 + } 329 + 330 + // Run v4 migration 331 + 3 -> apply_migration(conn, 4, migration_v4) 296 332 297 333 // Already at latest version 298 - 3 -> { 299 - logging.log(logging.Info, "Schema is up to date (v3)") 334 + 4 -> { 335 + logging.log(logging.Info, "Schema is up to date (v4)") 300 336 Ok(Nil) 301 337 } 302 338 303 339 // Future versions would be handled here: 304 - // 3 -> apply_migration(conn, 4, migration_v4) 305 340 // 4 -> apply_migration(conn, 5, migration_v5) 341 + // 5 -> apply_migration(conn, 6, migration_v6) 306 342 _ -> { 307 343 logging.log( 308 344 logging.Error, ··· 522 558 /// Inserts or updates a record in the database 523 559 /// Skips insertion if the CID already exists in the database (for any URI) 524 560 /// Also skips update if the URI exists with the same CID (content unchanged) 561 + /// Result of inserting a record 562 + pub type InsertResult { 563 + /// Record was newly inserted or updated 564 + Inserted 565 + /// Record was skipped (duplicate CID) 566 + Skipped 567 + } 568 + 525 569 pub fn insert_record( 526 570 conn: sqlight.Connection, 527 571 uri: String, ··· 529 573 did: String, 530 574 collection: String, 531 575 json: String, 532 - ) -> Result(Nil, sqlight.Error) { 576 + ) -> Result(InsertResult, sqlight.Error) { 533 577 // Check if this CID already exists in the database 534 578 use existing_cids <- result.try(get_existing_cids(conn, [uri])) 535 579 536 580 case dict.get(existing_cids, uri) { 537 581 // URI exists with same CID - skip update (content unchanged) 538 - Ok(existing_cid) if existing_cid == cid -> Ok(Nil) 582 + Ok(existing_cid) if existing_cid == cid -> Ok(Skipped) 539 583 // URI exists with different CID - proceed with update 540 584 // URI doesn't exist - proceed with insert 541 585 _ -> { 542 - // Check if this CID exists for any other URI 543 - let check_cid_sql = 586 + let sql = 544 587 " 545 - SELECT COUNT(*) as count 546 - FROM record 547 - WHERE cid = ? 588 + INSERT INTO record (uri, cid, did, collection, json) 589 + VALUES (?, ?, ?, ?, ?) 590 + ON CONFLICT(uri) DO UPDATE SET 591 + cid = excluded.cid, 592 + json = excluded.json, 593 + indexed_at = datetime('now') 548 594 " 549 595 550 - let count_decoder = { 551 - use count <- decode.field(0, decode.int) 552 - decode.success(count) 553 - } 554 - 555 - use cid_exists <- result.try(case 556 - sqlight.query( 557 - check_cid_sql, 558 - on: conn, 559 - with: [sqlight.text(cid)], 560 - expecting: count_decoder, 561 - ) 562 - { 563 - Ok([count]) if count > 0 -> Ok(True) 564 - Ok(_) -> Ok(False) 565 - Error(err) -> Error(err) 566 - }) 567 - 568 - case cid_exists { 569 - True -> Ok(Nil) 570 - False -> { 571 - let sql = 572 - " 573 - INSERT INTO record (uri, cid, did, collection, json) 574 - VALUES (?, ?, ?, ?, ?) 575 - ON CONFLICT(uri) DO UPDATE SET 576 - cid = excluded.cid, 577 - json = excluded.json, 578 - indexed_at = datetime('now') 579 - " 580 - 581 - use _ <- result.try(sqlight.query( 582 - sql, 583 - on: conn, 584 - with: [ 585 - sqlight.text(uri), 586 - sqlight.text(cid), 587 - sqlight.text(did), 588 - sqlight.text(collection), 589 - sqlight.text(json), 590 - ], 591 - expecting: decode.string, 592 - )) 593 - Ok(Nil) 594 - } 595 - } 596 + use _ <- result.try(sqlight.query( 597 + sql, 598 + on: conn, 599 + with: [ 600 + sqlight.text(uri), 601 + sqlight.text(cid), 602 + sqlight.text(did), 603 + sqlight.text(collection), 604 + sqlight.text(json), 605 + ], 606 + expecting: decode.string, 607 + )) 608 + Ok(Inserted) 596 609 } 597 610 } 598 611 } ··· 1147 1160 use json <- decode.field(1, decode.string) 1148 1161 use created_at <- decode.field(2, decode.string) 1149 1162 decode.success(Lexicon(id:, json:, created_at:)) 1150 - } 1151 - 1152 - sqlight.query(sql, on: conn, with: [], expecting: decoder) 1153 - } 1154 - 1155 - pub type ActivityPoint { 1156 - ActivityPoint(timestamp: String, count: Int) 1157 - } 1158 - 1159 - /// Gets record indexing activity over time 1160 - /// Returns hourly counts for the specified duration 1161 - pub fn get_record_activity( 1162 - conn: sqlight.Connection, 1163 - duration_hours: Int, 1164 - ) -> Result(List(ActivityPoint), sqlight.Error) { 1165 - // SQLite datetime calculation for cutoff time 1166 - let sql = " 1167 - WITH RECURSIVE time_series AS ( 1168 - SELECT datetime('now', '-" <> int.to_string(duration_hours) <> " hours') AS bucket 1169 - UNION ALL 1170 - SELECT datetime(bucket, '+1 hour') 1171 - FROM time_series 1172 - WHERE bucket < datetime('now') 1173 - ) 1174 - SELECT 1175 - strftime('%Y-%m-%dT%H:00:00Z', ts.bucket) as timestamp, 1176 - COALESCE(COUNT(r.uri), 0) as count 1177 - FROM time_series ts 1178 - LEFT JOIN record r ON 1179 - datetime(r.indexed_at) >= datetime(ts.bucket) 1180 - AND datetime(r.indexed_at) < datetime(ts.bucket, '+1 hour') 1181 - AND datetime(r.indexed_at) >= datetime('now', '-" <> int.to_string( 1182 - duration_hours, 1183 - ) <> " hours') 1184 - GROUP BY ts.bucket 1185 - ORDER BY ts.bucket ASC 1186 - " 1187 - 1188 - let decoder = { 1189 - use timestamp <- decode.field(0, decode.string) 1190 - use count <- decode.field(1, decode.int) 1191 - decode.success(ActivityPoint(timestamp:, count:)) 1192 1163 } 1193 1164 1194 1165 sqlight.query(sql, on: conn, with: [], expecting: decoder)
+319 -1
server/src/event_handler.gleam
··· 3 3 import database 4 4 import gleam/dynamic.{type Dynamic} 5 5 import gleam/dynamic/decode 6 + import gleam/json 6 7 import gleam/list 7 8 import gleam/option 8 9 import gleam/string 9 10 import goose 11 + import jetstream_activity 10 12 import lexicon 11 13 import logging 12 14 import pubsub ··· 49 51 @external(erlang, "event_handler_ffi", "microseconds_to_iso8601") 50 52 fn microseconds_to_iso8601(time_us: Int) -> String 51 53 54 + /// Serialize a commit event to JSON string for activity logging 55 + fn serialize_commit_event( 56 + did: String, 57 + time_us: Int, 58 + commit: goose.CommitData, 59 + ) -> String { 60 + let record_json = case commit.record { 61 + option.Some(record_data) -> json.string(dynamic_to_json(record_data)) 62 + option.None -> json.null() 63 + } 64 + 65 + let cid_json = case commit.cid { 66 + option.Some(cid) -> json.string(cid) 67 + option.None -> json.null() 68 + } 69 + 70 + json.object([ 71 + #("did", json.string(did)), 72 + #("time_us", json.int(time_us)), 73 + #( 74 + "commit", 75 + json.object([ 76 + #("rev", json.string(commit.rev)), 77 + #("operation", json.string(commit.operation)), 78 + #("collection", json.string(commit.collection)), 79 + #("rkey", json.string(commit.rkey)), 80 + #("record", record_json), 81 + #("cid", cid_json), 82 + ]), 83 + ), 84 + ]) 85 + |> json.to_string 86 + } 87 + 52 88 /// Handle a commit event (create, update, or delete) 53 89 pub fn handle_commit_event( 54 90 db: sqlight.Connection, ··· 60 96 ) -> Nil { 61 97 let uri = "at://" <> did <> "/" <> commit.collection <> "/" <> commit.rkey 62 98 99 + // Log activity at entry point - serialize the commit event to JSON 100 + let event_json = serialize_commit_event(did, time_us, commit) 101 + let timestamp = microseconds_to_iso8601(time_us) 102 + 103 + let activity_id = case 104 + jetstream_activity.log_activity( 105 + db, 106 + timestamp, 107 + commit.operation, 108 + commit.collection, 109 + did, 110 + event_json, 111 + ) 112 + { 113 + Ok(id) -> option.Some(id) 114 + Error(err) -> { 115 + logging.log( 116 + logging.Warning, 117 + "[jetstream] Failed to log activity: " <> string.inspect(err), 118 + ) 119 + option.None 120 + } 121 + } 122 + 63 123 case commit.operation { 64 124 "create" | "update" -> { 65 125 // Extract record and cid from options ··· 132 192 json_string, 133 193 ) 134 194 { 135 - Ok(_) -> { 195 + Ok(database.Inserted) -> { 136 196 logging.log( 137 197 logging.Info, 138 198 "[jetstream] " ··· 148 208 <> did, 149 209 ) 150 210 211 + // Update activity status to success 212 + case activity_id { 213 + option.Some(id) -> { 214 + case 215 + jetstream_activity.update_status( 216 + db, 217 + id, 218 + "success", 219 + option.None, 220 + ) 221 + { 222 + Ok(_) -> 223 + // Publish activity event for real-time UI updates 224 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 225 + id, 226 + timestamp, 227 + commit.operation, 228 + commit.collection, 229 + did, 230 + "success", 231 + option.None, 232 + event_json, 233 + )) 234 + Error(_) -> Nil 235 + } 236 + } 237 + option.None -> Nil 238 + } 239 + 151 240 // Publish event to PubSub for GraphQL subscriptions 152 241 let operation = case is_create { 153 242 True -> pubsub.Create ··· 176 265 False -> Nil 177 266 } 178 267 } 268 + Ok(database.Skipped) -> { 269 + logging.log( 270 + logging.Info, 271 + "[jetstream] skipped (duplicate CID) " 272 + <> commit.collection 273 + <> " (" 274 + <> commit.rkey 275 + <> ") " 276 + <> did, 277 + ) 278 + 279 + // Update activity status to success (but don't increment counters) 280 + case activity_id { 281 + option.Some(id) -> { 282 + case 283 + jetstream_activity.update_status( 284 + db, 285 + id, 286 + "success", 287 + option.Some("Skipped: duplicate CID"), 288 + ) 289 + { 290 + Ok(_) -> 291 + // Publish activity event for real-time UI updates 292 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 293 + id, 294 + timestamp, 295 + commit.operation, 296 + commit.collection, 297 + did, 298 + "success", 299 + option.Some("Skipped: duplicate CID"), 300 + event_json, 301 + )) 302 + Error(_) -> Nil 303 + } 304 + } 305 + option.None -> Nil 306 + } 307 + // Don't publish RecordCreated event - record wasn't actually created 308 + } 179 309 Error(err) -> { 180 310 logging.log( 181 311 logging.Error, ··· 184 314 <> ": " 185 315 <> string.inspect(err), 186 316 ) 317 + 318 + // Update activity status to error 319 + case activity_id { 320 + option.Some(id) -> { 321 + case 322 + jetstream_activity.update_status( 323 + db, 324 + id, 325 + "error", 326 + option.Some( 327 + "Database insert failed: " 328 + <> string.inspect(err), 329 + ), 330 + ) 331 + { 332 + Ok(_) -> { 333 + let error_msg = 334 + "Database insert failed: " <> string.inspect(err) 335 + // Publish activity event for real-time UI updates 336 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 337 + id, 338 + timestamp, 339 + commit.operation, 340 + commit.collection, 341 + did, 342 + "error", 343 + option.Some(error_msg), 344 + event_json, 345 + )) 346 + } 347 + Error(_) -> Nil 348 + } 349 + } 350 + option.None -> Nil 351 + } 187 352 } 188 353 } 189 354 } ··· 195 360 <> ": " 196 361 <> actor_err, 197 362 ) 363 + 364 + // Update activity status to error 365 + case activity_id { 366 + option.Some(id) -> { 367 + case 368 + jetstream_activity.update_status( 369 + db, 370 + id, 371 + "error", 372 + option.Some("Actor validation failed: " <> actor_err), 373 + ) 374 + { 375 + Ok(_) -> { 376 + let error_msg = "Actor validation failed: " <> actor_err 377 + // Publish activity event for real-time UI updates 378 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 379 + id, 380 + timestamp, 381 + commit.operation, 382 + commit.collection, 383 + did, 384 + "error", 385 + option.Some(error_msg), 386 + event_json, 387 + )) 388 + } 389 + Error(_) -> Nil 390 + } 391 + } 392 + option.None -> Nil 393 + } 198 394 } 199 395 } 200 396 } ··· 206 402 <> ": " 207 403 <> lexicon.describe_error(validation_error), 208 404 ) 405 + 406 + // Update activity status to validation_error 407 + case activity_id { 408 + option.Some(id) -> { 409 + case 410 + jetstream_activity.update_status( 411 + db, 412 + id, 413 + "validation_error", 414 + option.Some(lexicon.describe_error(validation_error)), 415 + ) 416 + { 417 + Ok(_) -> { 418 + let error_msg = lexicon.describe_error(validation_error) 419 + // Publish activity event for real-time UI updates 420 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 421 + id, 422 + timestamp, 423 + commit.operation, 424 + commit.collection, 425 + did, 426 + "validation_error", 427 + option.Some(error_msg), 428 + event_json, 429 + )) 430 + } 431 + Error(_) -> Nil 432 + } 433 + } 434 + option.None -> Nil 435 + } 209 436 } 210 437 } 211 438 } ··· 215 442 "[jetstream] Failed to fetch lexicons for validation: " 216 443 <> string.inspect(db_err), 217 444 ) 445 + 446 + // Update activity status to error 447 + case activity_id { 448 + option.Some(id) -> { 449 + let _ = 450 + jetstream_activity.update_status( 451 + db, 452 + id, 453 + "error", 454 + option.Some( 455 + "Failed to fetch lexicons: " <> string.inspect(db_err), 456 + ), 457 + ) 458 + Nil 459 + } 460 + option.None -> Nil 461 + } 218 462 } 219 463 } 220 464 } ··· 226 470 <> " event missing record or cid for " 227 471 <> uri, 228 472 ) 473 + 474 + // Update activity status to error 475 + case activity_id { 476 + option.Some(id) -> { 477 + let _ = 478 + jetstream_activity.update_status( 479 + db, 480 + id, 481 + "error", 482 + option.Some("Event missing record or cid"), 483 + ) 484 + Nil 485 + } 486 + option.None -> Nil 487 + } 229 488 } 230 489 } 231 490 } ··· 242 501 243 502 case database.delete_record(db, uri) { 244 503 Ok(_) -> { 504 + // Update activity status to success 505 + case activity_id { 506 + option.Some(id) -> { 507 + case 508 + jetstream_activity.update_status( 509 + db, 510 + id, 511 + "success", 512 + option.None, 513 + ) 514 + { 515 + Ok(_) -> 516 + // Publish activity event for real-time UI updates 517 + stats_pubsub.publish(stats_pubsub.ActivityLogged( 518 + id, 519 + timestamp, 520 + commit.operation, 521 + commit.collection, 522 + did, 523 + "success", 524 + option.None, 525 + event_json, 526 + )) 527 + Error(_) -> Nil 528 + } 529 + } 530 + option.None -> Nil 531 + } 532 + 245 533 // Publish delete event to PubSub for GraphQL subscriptions 246 534 // Use the event timestamp from the Jetstream event 247 535 let indexed_at = microseconds_to_iso8601(time_us) ··· 267 555 logging.Error, 268 556 "[jetstream] Failed to delete: " <> string.inspect(err), 269 557 ) 558 + 559 + // Update activity status to error 560 + case activity_id { 561 + option.Some(id) -> { 562 + let _ = 563 + jetstream_activity.update_status( 564 + db, 565 + id, 566 + "error", 567 + option.Some("Delete failed: " <> string.inspect(err)), 568 + ) 569 + Nil 570 + } 571 + option.None -> Nil 572 + } 270 573 } 271 574 } 272 575 } ··· 275 578 logging.Warning, 276 579 "[jetstream] Unknown operation: " <> commit.operation, 277 580 ) 581 + 582 + // Update activity status to error 583 + case activity_id { 584 + option.Some(id) -> { 585 + let _ = 586 + jetstream_activity.update_status( 587 + db, 588 + id, 589 + "error", 590 + option.Some("Unknown operation: " <> commit.operation), 591 + ) 592 + Nil 593 + } 594 + option.None -> Nil 595 + } 278 596 } 279 597 } 280 598 }
+293
server/src/jetstream_activity.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/int 3 + import gleam/option.{type Option, None, Some} 4 + import gleam/result 5 + import logging 6 + import sqlight 7 + 8 + pub type ActivityEntry { 9 + ActivityEntry( 10 + id: Int, 11 + timestamp: String, 12 + operation: String, 13 + collection: String, 14 + did: String, 15 + status: String, 16 + error_message: Option(String), 17 + event_json: String, 18 + ) 19 + } 20 + 21 + /// Logs a new JetStream activity entry 22 + pub fn log_activity( 23 + conn: sqlight.Connection, 24 + timestamp: String, 25 + operation: String, 26 + collection: String, 27 + did: String, 28 + event_json: String, 29 + ) -> Result(Int, sqlight.Error) { 30 + let sql = 31 + " 32 + INSERT INTO jetstream_activity (timestamp, operation, collection, did, status, event_json) 33 + VALUES (?, ?, ?, ?, 'processing', ?) 34 + RETURNING id 35 + " 36 + 37 + let decoder = { 38 + use id <- decode.field(0, decode.int) 39 + decode.success(id) 40 + } 41 + 42 + use results <- result.try(sqlight.query( 43 + sql, 44 + on: conn, 45 + with: [ 46 + sqlight.text(timestamp), 47 + sqlight.text(operation), 48 + sqlight.text(collection), 49 + sqlight.text(did), 50 + sqlight.text(event_json), 51 + ], 52 + expecting: decoder, 53 + )) 54 + 55 + case results { 56 + [id] -> Ok(id) 57 + _ -> 58 + Error(sqlight.SqlightError( 59 + sqlight.ConstraintForeignkey, 60 + "Failed to insert activity", 61 + -1, 62 + )) 63 + } 64 + } 65 + 66 + /// Updates the status of an activity entry 67 + pub fn update_status( 68 + conn: sqlight.Connection, 69 + id: Int, 70 + status: String, 71 + error_message: Option(String), 72 + ) -> Result(Nil, sqlight.Error) { 73 + let sql = 74 + " 75 + UPDATE jetstream_activity 76 + SET status = ?, error_message = ? 77 + WHERE id = ? 78 + " 79 + 80 + let error_value = case error_message { 81 + Some(msg) -> sqlight.text(msg) 82 + None -> sqlight.null() 83 + } 84 + 85 + use _ <- result.try(sqlight.query( 86 + sql, 87 + on: conn, 88 + with: [sqlight.text(status), error_value, sqlight.int(id)], 89 + expecting: decode.string, 90 + )) 91 + Ok(Nil) 92 + } 93 + 94 + /// Gets recent activity entries for the last N hours 95 + pub fn get_recent_activity( 96 + conn: sqlight.Connection, 97 + hours: Int, 98 + ) -> Result(List(ActivityEntry), sqlight.Error) { 99 + let sql = 100 + " 101 + SELECT id, timestamp, operation, collection, did, status, error_message, event_json 102 + FROM jetstream_activity 103 + WHERE datetime(timestamp) >= datetime('now', '-" <> int.to_string(hours) <> " hours') 104 + ORDER BY timestamp DESC 105 + LIMIT 1000 106 + " 107 + 108 + let decoder = { 109 + use id <- decode.field(0, decode.int) 110 + use timestamp <- decode.field(1, decode.string) 111 + use operation <- decode.field(2, decode.string) 112 + use collection <- decode.field(3, decode.string) 113 + use did <- decode.field(4, decode.string) 114 + use status <- decode.field(5, decode.string) 115 + use error_message <- decode.field(6, decode.optional(decode.string)) 116 + use event_json <- decode.field(7, decode.string) 117 + decode.success(ActivityEntry( 118 + id:, 119 + timestamp:, 120 + operation:, 121 + collection:, 122 + did:, 123 + status:, 124 + error_message:, 125 + event_json:, 126 + )) 127 + } 128 + 129 + sqlight.query(sql, on: conn, with: [], expecting: decoder) 130 + } 131 + 132 + /// Deletes activity entries older than N hours 133 + pub fn cleanup_old_activity( 134 + conn: sqlight.Connection, 135 + hours: Int, 136 + ) -> Result(Nil, sqlight.Error) { 137 + let sql = 138 + " 139 + DELETE FROM jetstream_activity 140 + WHERE datetime(timestamp) < datetime('now', '-" <> int.to_string(hours) <> " hours') 141 + " 142 + 143 + use _ <- result.try(sqlight.exec(sql, conn)) 144 + logging.log( 145 + logging.Info, 146 + "Cleaned up jetstream_activity entries older than " 147 + <> int.to_string(hours) 148 + <> " hours", 149 + ) 150 + Ok(Nil) 151 + } 152 + 153 + /// Extracts display information from event JSON 154 + /// Note: This is a simplified version that doesn't parse JSON yet 155 + /// Full JSON parsing would need proper decoders 156 + pub fn extract_display_info( 157 + _event_json_str: String, 158 + ) -> #(Option(String), Option(String), Option(String)) { 159 + // TODO: Implement proper JSON parsing with gleam_json or similar 160 + // For now, return None for all fields - the JSON is stored and can be viewed raw 161 + #(None, None, None) 162 + } 163 + 164 + /// Activity bucket for aggregated data 165 + pub type ActivityBucket { 166 + ActivityBucket( 167 + timestamp: String, 168 + create_count: Int, 169 + update_count: Int, 170 + delete_count: Int, 171 + ) 172 + } 173 + 174 + /// Get activity aggregated into 5-minute buckets for the last hour 175 + pub fn get_activity_1hr( 176 + conn: sqlight.Connection, 177 + ) -> Result(List(ActivityBucket), sqlight.Error) { 178 + get_activity_bucketed(conn, 1, "5 minutes", 12) 179 + } 180 + 181 + /// Get activity aggregated into 15-minute buckets for the last 3 hours 182 + pub fn get_activity_3hr( 183 + conn: sqlight.Connection, 184 + ) -> Result(List(ActivityBucket), sqlight.Error) { 185 + get_activity_bucketed(conn, 3, "15 minutes", 12) 186 + } 187 + 188 + /// Get activity aggregated into 30-minute buckets for the last 6 hours 189 + pub fn get_activity_6hr( 190 + conn: sqlight.Connection, 191 + ) -> Result(List(ActivityBucket), sqlight.Error) { 192 + get_activity_bucketed(conn, 6, "30 minutes", 12) 193 + } 194 + 195 + /// Get activity aggregated into hourly buckets for the last day 196 + pub fn get_activity_1day( 197 + conn: sqlight.Connection, 198 + ) -> Result(List(ActivityBucket), sqlight.Error) { 199 + get_activity_bucketed(conn, 24, "1 hour", 24) 200 + } 201 + 202 + /// Get activity aggregated into daily buckets for the last 7 days 203 + pub fn get_activity_7day( 204 + conn: sqlight.Connection, 205 + ) -> Result(List(ActivityBucket), sqlight.Error) { 206 + // For 7 days, we need to bucket by day (6 days ago through today = 7 days) 207 + let sql = 208 + " 209 + WITH RECURSIVE time_series(bucket, n) AS ( 210 + SELECT datetime('now', 'start of day', '-6 days'), 0 211 + UNION ALL 212 + SELECT datetime(bucket, '+1 day'), n + 1 213 + FROM time_series 214 + WHERE n <= 5 215 + ) 216 + SELECT 217 + strftime('%Y-%m-%dT00:00:00Z', ts.bucket) as timestamp, 218 + COALESCE(SUM(CASE WHEN a.operation = 'create' THEN 1 ELSE 0 END), 0) as create_count, 219 + COALESCE(SUM(CASE WHEN a.operation = 'update' THEN 1 ELSE 0 END), 0) as update_count, 220 + COALESCE(SUM(CASE WHEN a.operation = 'delete' THEN 1 ELSE 0 END), 0) as delete_count 221 + FROM time_series ts 222 + LEFT JOIN jetstream_activity a ON 223 + date(a.timestamp) = date(ts.bucket) 224 + AND a.status = 'success' 225 + GROUP BY ts.bucket 226 + ORDER BY ts.bucket 227 + " 228 + 229 + let decoder = { 230 + use timestamp <- decode.field(0, decode.string) 231 + use create_count <- decode.field(1, decode.int) 232 + use update_count <- decode.field(2, decode.int) 233 + use delete_count <- decode.field(3, decode.int) 234 + decode.success(ActivityBucket( 235 + timestamp:, 236 + create_count:, 237 + update_count:, 238 + delete_count:, 239 + )) 240 + } 241 + 242 + sqlight.query(sql, on: conn, with: [], expecting: decoder) 243 + } 244 + 245 + /// Helper function to get activity bucketed by a specific interval 246 + fn get_activity_bucketed( 247 + conn: sqlight.Connection, 248 + hours: Int, 249 + interval: String, 250 + expected_buckets: Int, 251 + ) -> Result(List(ActivityBucket), sqlight.Error) { 252 + let hours_str = int.to_string(hours) 253 + let max_n = int.to_string(expected_buckets) 254 + 255 + // Build the SQL dynamically based on interval 256 + let sql = 257 + " 258 + WITH RECURSIVE time_series(bucket, n) AS ( 259 + SELECT datetime('now', '-" <> hours_str <> " hours'), 0 260 + UNION ALL 261 + SELECT datetime(bucket, '+" <> interval <> "'), n + 1 262 + FROM time_series 263 + WHERE n < " <> max_n <> " 264 + ) 265 + SELECT 266 + strftime('%Y-%m-%dT%H:%M:00Z', ts.bucket) as timestamp, 267 + COALESCE(SUM(CASE WHEN a.operation = 'create' THEN 1 ELSE 0 END), 0) as create_count, 268 + COALESCE(SUM(CASE WHEN a.operation = 'update' THEN 1 ELSE 0 END), 0) as update_count, 269 + COALESCE(SUM(CASE WHEN a.operation = 'delete' THEN 1 ELSE 0 END), 0) as delete_count 270 + FROM time_series ts 271 + LEFT JOIN jetstream_activity a ON 272 + datetime(a.timestamp) >= ts.bucket 273 + AND datetime(a.timestamp) < datetime(ts.bucket, '+" <> interval <> "') 274 + AND a.status = 'success' 275 + GROUP BY ts.bucket 276 + ORDER BY ts.bucket 277 + " 278 + 279 + let decoder = { 280 + use timestamp <- decode.field(0, decode.string) 281 + use create_count <- decode.field(1, decode.int) 282 + use update_count <- decode.field(2, decode.int) 283 + use delete_count <- decode.field(3, decode.int) 284 + decode.success(ActivityBucket( 285 + timestamp:, 286 + create_count:, 287 + update_count:, 288 + delete_count:, 289 + )) 290 + } 291 + 292 + sqlight.query(sql, on: conn, with: [], expecting: decoder) 293 + }
+163
server/src/lustre_handlers.gleam
··· 4 4 /// for Lustre server components, including serving the client runtime and 5 5 /// managing component WebSocket connections. 6 6 import backfill_state 7 + import components/activity_chart 7 8 import components/backfill_button 9 + import components/jetstream_activity_log 8 10 import components/stats_cards 9 11 import config 10 12 import gleam/bytes_tree ··· 236 238 lustre.shutdown() 237 239 |> lustre.send(to: state.component) 238 240 } 241 + 242 + // JETSTREAM ACTIVITY LOG COMPONENT 243 + 244 + /// WebSocket handler for jetstream activity log component 245 + pub fn serve_activity_log( 246 + req: request.Request(mist.Connection), 247 + db: sqlight.Connection, 248 + ) -> response.Response(mist.ResponseData) { 249 + mist.websocket( 250 + request: req, 251 + on_init: init_activity_log_socket(db, _), 252 + handler: loop_activity_log_socket, 253 + on_close: close_activity_log_socket, 254 + ) 255 + } 256 + 257 + type ActivityLogSocket { 258 + ActivityLogSocket( 259 + component: lustre.Runtime(jetstream_activity_log.Msg), 260 + self: process.Subject( 261 + server_component.ClientMessage(jetstream_activity_log.Msg), 262 + ), 263 + ) 264 + } 265 + 266 + type ActivityLogSocketMessage = 267 + server_component.ClientMessage(jetstream_activity_log.Msg) 268 + 269 + type ActivityLogSocketInit = 270 + #(ActivityLogSocket, option.Option(process.Selector(ActivityLogSocketMessage))) 271 + 272 + fn init_activity_log_socket( 273 + db: sqlight.Connection, 274 + _connection: mist.WebsocketConnection, 275 + ) -> ActivityLogSocketInit { 276 + let component = jetstream_activity_log.component(db) 277 + let assert Ok(runtime) = lustre.start_server_component(component, Nil) 278 + 279 + let self = process.new_subject() 280 + let selector = process.new_selector() |> process.select(self) 281 + 282 + server_component.register_subject(self) 283 + |> lustre.send(to: runtime) 284 + 285 + #(ActivityLogSocket(component: runtime, self: self), option.Some(selector)) 286 + } 287 + 288 + fn loop_activity_log_socket( 289 + state: ActivityLogSocket, 290 + message: mist.WebsocketMessage(ActivityLogSocketMessage), 291 + connection: mist.WebsocketConnection, 292 + ) -> mist.Next(ActivityLogSocket, ActivityLogSocketMessage) { 293 + case message { 294 + mist.Text(json_string) -> { 295 + case json.parse(json_string, server_component.runtime_message_decoder()) { 296 + Ok(runtime_message) -> lustre.send(state.component, runtime_message) 297 + Error(_) -> Nil 298 + } 299 + 300 + mist.continue(state) 301 + } 302 + 303 + mist.Binary(_) -> mist.continue(state) 304 + 305 + mist.Custom(client_message) -> { 306 + let json_obj = server_component.client_message_to_json(client_message) 307 + let assert Ok(_) = 308 + mist.send_text_frame(connection, json.to_string(json_obj)) 309 + 310 + mist.continue(state) 311 + } 312 + 313 + mist.Closed | mist.Shutdown -> mist.stop() 314 + } 315 + } 316 + 317 + fn close_activity_log_socket(state: ActivityLogSocket) -> Nil { 318 + lustre.shutdown() 319 + |> lustre.send(to: state.component) 320 + } 321 + 322 + // ACTIVITY CHART COMPONENT 323 + 324 + /// WebSocket handler for activity chart component 325 + pub fn serve_activity_chart( 326 + req: request.Request(mist.Connection), 327 + db: sqlight.Connection, 328 + ) -> response.Response(mist.ResponseData) { 329 + mist.websocket( 330 + request: req, 331 + on_init: init_activity_chart_socket(db, _), 332 + handler: loop_activity_chart_socket, 333 + on_close: close_activity_chart_socket, 334 + ) 335 + } 336 + 337 + type ActivityChartSocket { 338 + ActivityChartSocket( 339 + component: lustre.Runtime(activity_chart.Msg), 340 + self: process.Subject(server_component.ClientMessage(activity_chart.Msg)), 341 + ) 342 + } 343 + 344 + type ActivityChartSocketMessage = 345 + server_component.ClientMessage(activity_chart.Msg) 346 + 347 + type ActivityChartSocketInit = 348 + #( 349 + ActivityChartSocket, 350 + option.Option(process.Selector(ActivityChartSocketMessage)), 351 + ) 352 + 353 + fn init_activity_chart_socket( 354 + db: sqlight.Connection, 355 + _connection: mist.WebsocketConnection, 356 + ) -> ActivityChartSocketInit { 357 + let component = activity_chart.component(db) 358 + let assert Ok(runtime) = lustre.start_server_component(component, Nil) 359 + 360 + let self = process.new_subject() 361 + let selector = process.new_selector() |> process.select(self) 362 + 363 + server_component.register_subject(self) 364 + |> lustre.send(to: runtime) 365 + 366 + #(ActivityChartSocket(component: runtime, self: self), option.Some(selector)) 367 + } 368 + 369 + fn loop_activity_chart_socket( 370 + state: ActivityChartSocket, 371 + message: mist.WebsocketMessage(ActivityChartSocketMessage), 372 + connection: mist.WebsocketConnection, 373 + ) -> mist.Next(ActivityChartSocket, ActivityChartSocketMessage) { 374 + case message { 375 + mist.Text(json_string) -> { 376 + case json.parse(json_string, server_component.runtime_message_decoder()) { 377 + Ok(runtime_message) -> lustre.send(state.component, runtime_message) 378 + Error(_) -> Nil 379 + } 380 + 381 + mist.continue(state) 382 + } 383 + 384 + mist.Binary(_) -> mist.continue(state) 385 + 386 + mist.Custom(client_message) -> { 387 + let json_obj = server_component.client_message_to_json(client_message) 388 + let assert Ok(_) = 389 + mist.send_text_frame(connection, json.to_string(json_obj)) 390 + 391 + mist.continue(state) 392 + } 393 + 394 + mist.Closed | mist.Shutdown -> mist.stop() 395 + } 396 + } 397 + 398 + fn close_activity_chart_socket(state: ActivityChartSocket) -> Nil { 399 + lustre.shutdown() 400 + |> lustre.send(to: state.component) 401 + }
+50 -66
server/src/pages/index.gleam
··· 1 + import components/activity_chart 1 2 import components/alert 2 3 import components/backfill_button 3 4 import components/button 4 - import components/collection_table 5 + import components/jetstream_activity_log 5 6 import components/layout 6 - import components/sparkline 7 7 import components/stats_cards 8 8 import database 9 9 import gleam/option.{type Option} 10 + import gleam/result 11 + import jetstream_activity 10 12 import lustre/attribute 11 13 import lustre/element.{type Element} 12 14 import lustre/element/html ··· 19 21 record_count: Int, 20 22 lexicon_count: Int, 21 23 actor_count: Int, 22 - collection_stats: List(database.CollectionStat), 23 24 record_lexicons: List(database.Lexicon), 24 - record_activity: List(database.ActivityPoint), 25 + activity_chart_data: List(jetstream_activity.ActivityBucket), 26 + jetstream_activity: List(jetstream_activity.ActivityEntry), 25 27 ) 26 28 } 27 29 ··· 52 54 let actor_count = case database.get_actor_count(db) { 53 55 Ok(count) -> count 54 56 Error(_) -> 0 55 - } 56 - 57 - let collection_stats = case database.get_collection_stats(db) { 58 - Ok(stats) -> stats 59 - Error(_) -> [] 60 57 } 61 58 62 59 let record_lexicons = case database.get_record_type_lexicons(db) { ··· 64 61 Error(_) -> [] 65 62 } 66 63 67 - let record_activity = case database.get_record_activity(db, 168) { 68 - Ok(activity) -> activity 69 - Error(_) -> [] 70 - } 64 + // Get 1-day activity data for the chart (default view) 65 + let activity_chart_data = jetstream_activity.get_activity_1day(db) 66 + |> result.unwrap([]) 67 + 68 + // Get last 24h of individual activity entries for the log 69 + let jetstream_activity_data = 70 + jetstream_activity.get_recent_activity(db, 168) 71 + |> result.unwrap([]) 71 72 72 73 IndexData( 73 74 record_count: record_count, 74 75 lexicon_count: lexicon_count, 75 76 actor_count: actor_count, 76 - collection_stats: collection_stats, 77 77 record_lexicons: record_lexicons, 78 - record_activity: record_activity, 78 + activity_chart_data: activity_chart_data, 79 + jetstream_activity: jetstream_activity_data, 79 80 ) 80 81 } 81 82 ··· 91 92 title: "ATProto Database Stats", 92 93 content: [ 93 94 render_alerts(domain_authority, data.lexicon_count), 94 - render_action_buttons(current_user), 95 + render_action_buttons(current_user, is_admin, backfilling), 95 96 // Real-time stats cards server component with initial content 96 97 server_component.element( 97 98 [attribute.id("stats-cards"), server_component.route("/stats-ws")], ··· 103 104 ), 104 105 ], 105 106 ), 106 - render_activity_section(data.record_activity), 107 - render_collections_section( 108 - data.collection_stats, 109 - data.record_lexicons, 110 - is_admin, 111 - backfilling, 112 - ), 107 + // Activity chart with time range selector 108 + html.div([attribute.class("mb-8")], [ 109 + server_component.element( 110 + [ 111 + attribute.id("activity-chart"), 112 + server_component.route("/activity-chart-ws"), 113 + ], 114 + [activity_chart.render_static(data.activity_chart_data, activity_chart.OneDay)], 115 + ), 116 + ]), 117 + // JetStream activity log with pre-rendered content 118 + html.div([attribute.class("mb-8")], [ 119 + server_component.element( 120 + [ 121 + attribute.id("activity-log"), 122 + server_component.route("/activity-ws"), 123 + ], 124 + [jetstream_activity_log.render_static(data.jetstream_activity)], 125 + ), 126 + ]), 113 127 ], 114 128 current_user: current_user, 115 129 domain_authority: domain_authority, ··· 159 173 /// Render action buttons for authenticated users 160 174 fn render_action_buttons( 161 175 current_user: Option(#(String, String)), 176 + is_admin: Bool, 177 + backfilling: Bool, 162 178 ) -> Element(msg) { 163 179 case current_user { 164 180 option.Some(_) -> { 165 181 html.div([attribute.class("mb-8 flex gap-3")], [ 166 182 button.link(href: "/graphiql", text: "Open GraphiQL"), 183 + case is_admin { 184 + True -> 185 + server_component.element( 186 + [ 187 + attribute.id("backfill-button"), 188 + server_component.route("/backfill-ws"), 189 + ], 190 + [backfill_button.render_button_static(is_admin, backfilling)], 191 + ) 192 + False -> element.none() 193 + }, 167 194 ]) 168 195 } 169 196 option.None -> element.none() 170 197 } 171 198 } 172 199 173 - /// Render the activity chart section 174 - fn render_activity_section( 175 - activity: List(database.ActivityPoint), 176 - ) -> Element(msg) { 177 - html.div([attribute.class("mb-8")], [ 178 - html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ 179 - html.div([attribute.class("text-sm text-zinc-500 mb-3")], [ 180 - element.text("Activity (Last 7 Days)"), 181 - ]), 182 - sparkline.view(activity), 183 - ]), 184 - ]) 185 - } 186 - 187 - /// Render the collections table section 188 - fn render_collections_section( 189 - collection_stats: List(database.CollectionStat), 190 - record_lexicons: List(database.Lexicon), 191 - is_admin: Bool, 192 - backfilling: Bool, 193 - ) -> Element(msg) { 194 - let backfill_button = case is_admin { 195 - True -> 196 - server_component.element( 197 - [ 198 - attribute.id("backfill-button"), 199 - server_component.route("/backfill-ws"), 200 - ], 201 - [backfill_button.render_button_static(is_admin, backfilling)], 202 - ) 203 - False -> element.none() 204 - } 205 - 206 - html.div([], [ 207 - html.div([attribute.class("flex justify-between items-center mb-4")], [ 208 - html.h2([attribute.class("text-2xl font-semibold text-zinc-300")], [ 209 - element.text("Collections"), 210 - ]), 211 - backfill_button, 212 - ]), 213 - collection_table.view(collection_stats, record_lexicons), 214 - ]) 215 - }
+46
server/src/server.gleam
··· 1 + import activity_cleanup 1 2 import argv 2 3 import backfill 3 4 import backfill_state ··· 233 234 // Initialize Stats PubSub registry for real-time stats 234 235 stats_pubsub.start() 235 236 logging.log(logging.Info, "[server] Stats PubSub registry initialized") 237 + 238 + // Start activity cleanup scheduler 239 + case activity_cleanup.start(db) { 240 + Ok(_cleanup_subject) -> 241 + logging.log( 242 + logging.Info, 243 + "[server] Activity cleanup scheduler started (runs hourly)", 244 + ) 245 + Error(err) -> 246 + logging.log( 247 + logging.Warning, 248 + "[server] Failed to start activity cleanup scheduler: " 249 + <> string.inspect(err), 250 + ) 251 + } 236 252 237 253 // Start Jetstream consumer in background 238 254 let jetstream_subject = case jetstream_consumer.start(db) { ··· 509 525 case string.lowercase(upgrade_value) { 510 526 "websocket" -> { 511 527 lustre_handlers.serve_stats_cards(req, ctx.db) 528 + } 529 + _ -> wisp_handler(req) 530 + } 531 + } 532 + _ -> wisp_handler(req) 533 + } 534 + } 535 + 536 + // Activity log WebSocket 537 + ["activity-ws"] -> { 538 + case upgrade_header { 539 + Ok(upgrade_value) -> { 540 + case string.lowercase(upgrade_value) { 541 + "websocket" -> { 542 + lustre_handlers.serve_activity_log(req, ctx.db) 543 + } 544 + _ -> wisp_handler(req) 545 + } 546 + } 547 + _ -> wisp_handler(req) 548 + } 549 + } 550 + 551 + // Activity chart WebSocket 552 + ["activity-chart-ws"] -> { 553 + case upgrade_header { 554 + Ok(upgrade_value) -> { 555 + case string.lowercase(upgrade_value) { 556 + "websocket" -> { 557 + lustre_handlers.serve_activity_chart(req, ctx.db) 512 558 } 513 559 _ -> wisp_handler(req) 514 560 }
+11
server/src/stats_pubsub.gleam
··· 1 1 import gleam/erlang/process.{type Subject} 2 2 import gleam/list 3 + import gleam/option 3 4 import group_registry 4 5 5 6 /// Event types for stats updates ··· 7 8 RecordCreated 8 9 RecordDeleted 9 10 ActorCreated 11 + ActivityLogged( 12 + id: Int, 13 + timestamp: String, 14 + operation: String, 15 + collection: String, 16 + did: String, 17 + status: String, 18 + error_message: option.Option(String), 19 + event_json: String, 20 + ) 10 21 } 11 22 12 23 /// The group name for all stats event subscriptions