sentiero 1.0.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- metadata +232 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.switch.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{height:1em;display:flex;align-items:center}.switch.disabled.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{opacity:.5}.label.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{margin:0 8px}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7.svelte-a6h7w7{position:absolute;opacity:0}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{width:2em;height:1em;position:relative;cursor:pointer;display:block}.switch.disabled.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{cursor:not-allowed}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:before{content:"";position:absolute;width:2em;height:1em;left:.1em;transition:background .1s ease;background:#4950f680;border-radius:50px}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:after{content:"";position:absolute;width:1em;height:1em;border-radius:50px;left:0;transition:all .2s ease;box-shadow:0 2px 5px #0000004d;background:#fcfff4;animation:switch-off .2s ease-out;z-index:2}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:before{background:#4950f6}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:after{animation:switch-on .2s ease-out;left:1.1em}.rr-controller.svelte-189zk2r.svelte-189zk2r{width:100%;height:80px;background:#fff;display:flex;flex-direction:column;justify-content:space-around;align-items:center;border-radius:0 0 5px 5px}.rr-timeline.svelte-189zk2r.svelte-189zk2r{width:80%;display:flex;align-items:center}.rr-timeline__time.svelte-189zk2r.svelte-189zk2r{display:inline-block;width:100px;text-align:center;color:#11103e}.rr-progress.svelte-189zk2r.svelte-189zk2r{flex:1;height:12px;background:#eee;position:relative;border-radius:3px;cursor:pointer;box-sizing:border-box;border-top:solid 4px #fff;border-bottom:solid 4px #fff}.rr-progress.disabled.svelte-189zk2r.svelte-189zk2r{cursor:not-allowed}.rr-progress__step.svelte-189zk2r.svelte-189zk2r{height:100%;position:absolute;left:0;top:0;background:#e0e1fe}.rr-progress__handler.svelte-189zk2r.svelte-189zk2r{width:20px;height:20px;border-radius:10px;position:absolute;top:2px;transform:translate(-50%,-50%);background:#4950f6}.rr-controller__btns.svelte-189zk2r.svelte-189zk2r{display:flex;align-items:center;justify-content:center;font-size:13px}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r{width:32px;height:32px;display:flex;padding:0;align-items:center;justify-content:center;background:none;border:none;border-radius:50%;cursor:pointer}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:active{background:#e0e1fe}.rr-controller__btns.svelte-189zk2r button.active.svelte-189zk2r{color:#fff;background:#4950f6}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:disabled{cursor:not-allowed}.replayer-wrapper{position:relative}.replayer-mouse{position:absolute;width:20px;height:20px;transition:left .05s linear,top .05s linear;background-size:contain;background-position:center center;background-repeat:no-repeat;background-image:url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==);border-color:transparent}.replayer-mouse:after{content:"";display:inline-block;width:20px;height:20px;background:#4950f6;border-radius:100%;transform:translate(-50%,-50%);opacity:.3}.replayer-mouse.active:after{animation:click .2s ease-in-out 1}.replayer-mouse.touch-device{background-image:none;width:70px;height:70px;border-width:4px;border-style:solid;border-radius:100%;margin-left:-37px;margin-top:-37px;border-color:#4950f600;transition:left 0s linear,top 0s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device.touch-active{border-color:#4950f6;transition:left .25s linear,top .25s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device:after{opacity:0}.replayer-mouse.touch-device.active:after{animation:touch-click .2s ease-in-out 1}.replayer-mouse-tail{position:absolute;pointer-events:none}@keyframes click{0%{opacity:.3;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}@keyframes touch-click{0%{opacity:0;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}.rr-player{position:relative;background:#fff;float:left;border-radius:5px;box-shadow:0 24px 48px #11103e1f}.rr-player__frame{overflow:hidden}.replayer-wrapper{float:left;clear:both;transform-origin:top left;left:50%;top:50%}.replayer-wrapper>iframe{border:none}
|
|
2
|
+
/*# sourceMappingURL=style.min.css.map */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var c=document.getElementById("select-all"),s=document.querySelectorAll(".session-checkbox"),i=document.getElementById("bulk-actions"),l=document.getElementById("selected-count"),d=document.getElementById("bulk-delete-form");function a(){let e=document.querySelectorAll(".session-checkbox:checked").length;i&&(i.style.display=e>0?"block":"none"),l&&(l.textContent=e),c&&(c.checked=e===s.length&&s.length>0)}c&&c.addEventListener("change",()=>{s.forEach(e=>{e.checked=c.checked}),a()});s.forEach(e=>{e.addEventListener("change",a)});d&&d.addEventListener("submit",e=>{let t=d.getAttribute("data-confirm");t&&!confirm(t)&&e.preventDefault()});document.addEventListener("click",e=>{let t=e.target.closest("[data-action='delete-session']");if(!t||!confirm("Delete this session?"))return;let r=t.getAttribute("data-session-id"),u=t.getAttribute("data-csrf-token"),m=t.getAttribute("data-base-path"),n=document.createElement("form");n.method="POST",n.action=`${m}/sessions/${r}?_method=delete`;let o=document.createElement("input");o.type="hidden",o.name="csrf_token",o.value=u,n.appendChild(o),document.body.appendChild(n),n.submit()});})();
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! tailwindcss v4.2.2 | 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-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--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;--tw-leading: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-outline-style:solid}}}@layer theme{:root,:host{--font-sans:"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-400:oklch(70.4% .191 22.216);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-900:oklch(39.6% .141 25.723);--color-blue-50:oklch(97% .014 254.604);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-900:oklch(35.9% .144 278.697);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--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);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wider:.05em;--leading-normal:1.5;--radius-sm:.25rem;--radius-md:.375rem;--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);--color-surface-subtle:#f8f9fb;--color-border:#e2e5ea;--color-border-light:#eef0f3;--color-primary:#2563eb;--color-primary-hover:#1d4ed8;--color-primary-light:#eff6ff;--color-success:#059669;--color-success-bg:#ecfdf5;--color-warning:#d97706;--color-warning-bg:#fffbeb;--color-danger:#dc2626;--color-danger-bg:#fef2f2;--color-danger-hover:#b91c1c;--color-info:#0891b2;--color-info-bg:#ecfeff;--sidebar-w:220px}}@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}body{--tw-leading:var(--leading-normal);font-size:13px;line-height:var(--leading-normal);color:var(--color-gray-900);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:var(--font-sans);background:var(--color-surface-subtle);margin:0}*,:before,:after{border-color:var(--color-border)}a{color:var(--color-primary);text-decoration-line:none}a:hover{color:var(--color-primary-hover)}h1,h2,h3,h4,h5,h6{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900)}}@layer components{.btn{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:1;--tw-font-weight:var(--font-weight-medium);line-height:1;font-weight:var(--font-weight-medium);white-space:nowrap;font-family:var(--font-sans);border-width:1px;border-color:#0000;text-decoration-line:none;transition:all .12s;display:inline-flex;box-shadow:0 1px 2px #0000000d}.btn:focus-visible{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-primary)}.btn-sm{padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);font-size:11px}.btn-xs{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1);font-size:11px}.btn-primary{color:var(--color-white);background:var(--color-gray-900);border-color:var(--color-gray-900)}.btn-primary:hover{color:var(--color-white);background:var(--color-gray-800);border-color:var(--color-gray-800)}.btn-secondary{background-color:var(--color-white);color:var(--color-gray-600);border-color:var(--color-border);box-shadow:0 1px 2px #0000000a}.btn-secondary:hover{background-color:var(--color-gray-50);color:var(--color-gray-800);border-color:var(--color-gray-300)}.btn-danger{color:var(--color-white);background:var(--color-danger);border-color:var(--color-danger)}.btn-danger:hover{color:var(--color-white);background:var(--color-danger-hover);border-color:var(--color-danger-hover)}.btn-ghost-danger{color:var(--color-gray-400);border-color:var(--color-border);box-shadow:none;background-color:#0000}.btn-ghost-danger:hover{color:var(--color-danger);background:var(--color-danger-bg);border-color:var(--color-danger)}.btn-active{color:var(--color-white);background:var(--color-primary);border-color:var(--color-primary)}.btn-active:hover{color:var(--color-white);background:var(--color-primary-hover);border-color:var(--color-primary-hover)}.input,.select{border-radius:var(--radius-md);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-900);font-family:var(--font-sans);border:1px solid var(--color-border);transition:border-color .12s,box-shadow .12s;box-shadow:0 1px 2px #0000000a}.select{appearance:none;padding-right:calc(var(--spacing) * 7);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-position:right .4rem center;background-repeat:no-repeat;background-size:.65rem}.input:focus,.select:focus{--tw-outline-style:none;border-color:var(--color-primary);outline-style:none;box-shadow:0 0 0 3px #2563eb14}.input::placeholder{color:var(--color-gray-400)}.label{margin-bottom:calc(var(--spacing) * 1);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--color-gray-400);text-transform:uppercase;display:block}.checkbox{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);cursor:pointer;border:1px solid var(--color-gray-300);accent-color:var(--color-primary);border-radius:.25rem}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);white-space:nowrap;font-family:var(--font-sans);letter-spacing:.01em;border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-neutral{background-color:var(--color-gray-100);color:var(--color-gray-500)}.badge-primary{background:var(--color-primary-light);color:var(--color-primary)}.badge-warning{background:var(--color-warning-bg);color:var(--color-warning)}.badge-danger{background:var(--color-danger-bg);color:var(--color-danger)}.badge-success{background:var(--color-success-bg);color:var(--color-success)}.badge-info{background:var(--color-info-bg);color:var(--color-info)}.card{border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.card-body{padding:calc(var(--spacing) * 5)}.data-table{text-align:left;width:100%;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-collapse:separate;border-spacing:0}.data-table th{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);white-space:nowrap;color:var(--color-gray-400);text-transform:uppercase;background:var(--color-surface-subtle);border-bottom:1px solid var(--color-border)}.data-table td{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);vertical-align:middle;color:var(--color-gray-600);border-bottom:1px solid var(--color-border-light)}.data-table tbody tr:last-child td{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.data-table tbody tr:hover td{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.data-table tbody tr:hover td{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.table-wrap{border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);overflow:hidden;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.pagination{margin:calc(var(--spacing) * 0);gap:calc(var(--spacing) * 1);padding:calc(var(--spacing) * 0);list-style-type:none;display:flex}.page-link{border-radius:var(--radius-md);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-600);border:1px solid var(--color-border);text-decoration-line:none;display:block}.page-link:hover{background-color:var(--color-gray-50);color:var(--color-gray-800);border-color:var(--color-gray-300)}.page-disabled .page-link{pointer-events:none;background-color:var(--color-gray-50);color:var(--color-gray-300)}.alert-error{margin-bottom:calc(var(--spacing) * 3);border-radius:var(--radius-lg);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));background:var(--color-danger-bg);color:var(--color-danger);border:1px solid #fecaca}.tabs{margin-bottom:calc(var(--spacing) * 5);align-items:center;gap:calc(var(--spacing) * 6);border-bottom:1px solid var(--color-border);display:flex}.tab{align-items:center;gap:calc(var(--spacing) * 1.5);padding-bottom:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;color:var(--color-gray-500);border-bottom:2px solid #0000;margin-bottom:-1px;text-decoration-line:none;transition:color .12s,border-color .12s;display:inline-flex}.tab:hover{color:var(--color-gray-800);border-bottom-color:var(--color-gray-300)}.tab-active,.tab-active:hover{color:var(--color-primary);border-bottom-color:var(--color-primary)}.tab-count{padding-inline:calc(var(--spacing) * 1.5);padding-block:calc(var(--spacing) * .5);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);background:var(--color-gray-100);color:var(--color-gray-500);border-radius:3.40282e38px}.tab-active .tab-count{background:var(--color-primary-light);color:var(--color-primary)}.banner-warning{margin-bottom:calc(var(--spacing) * 4);align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));background:var(--color-warning-bg);color:var(--color-warning);border:1px solid #fde68a;border-left:3px solid var(--color-warning);display:flex}.banner-warning strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}}@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.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}}.my-4{margin-block:calc(var(--spacing) * 4)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.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-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline\!{display:inline!important}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.w-3{width:calc(var(--spacing) * 3)}.w-8{width:calc(var(--spacing) * 8)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.w-px{width:1px}.max-w-0{max-width:calc(var(--spacing) * 0)}.max-w-full{max-width:100%}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.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}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[100px_1fr\]{grid-template-columns:100px 1fr}.grid-cols-\[140px_1fr\]{grid-template-columns:140px 1fr}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.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-dashed{--tw-border-style:dashed;border-style:dashed}.border-blue-400{border-color:var(--color-blue-400)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-indigo-400{border-color:var(--color-indigo-400)}.border-red-400{border-color:var(--color-red-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-red-50{background-color:var(--color-red-50)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.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-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}.text-\[11px\]{font-size:11px}.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-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-indigo-400{color:var(--color-indigo-400)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.blur{--tw-blur:blur(8px);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,)}.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))}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-blue-800:hover{color:var(--color-blue-800)}.hover\:text-indigo-900:hover{color:var(--color-indigo-900)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}}}.s-layout{min-height:100vh;display:flex}.s-sidebar{top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);left:calc(var(--spacing) * 0);z-index:50;background-color:var(--color-white);padding-block:calc(var(--spacing) * 4);width:var(--sidebar-w);border-right:1px solid var(--color-border);flex-direction:column;display:flex;position:fixed}.s-sidebar-brand{margin-bottom:calc(var(--spacing) * 5);align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900);text-decoration-line:none;display:flex}.s-sidebar-brand:hover{color:var(--color-gray-900)}.s-sidebar-brand-logo{height:calc(var(--spacing) * 7);width:calc(var(--spacing) * 7);border-radius:var(--radius-md);flex-shrink:0}.s-sidebar-nav{margin:calc(var(--spacing) * 0);padding-inline:calc(var(--spacing) * 2.5);list-style-type:none}.s-nav-item{margin-bottom:calc(var(--spacing) * .5);align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-500);padding-block:7px;text-decoration-line:none;transition:all .12s;display:flex}.s-nav-item:hover{background-color:var(--color-gray-100);color:var(--color-gray-800)}.s-nav-item.active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);background:var(--color-primary-light);color:var(--color-primary)}.s-nav-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);opacity:.6;flex-shrink:0}.s-nav-item.active .s-nav-icon{opacity:1}.s-main{padding-inline:calc(var(--spacing) * 8);padding-block:calc(var(--spacing) * 6);margin-left:var(--sidebar-w);max-width:calc(1400px + var(--sidebar-w));flex:1}.s-mobile-header{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-bottom:1px solid var(--color-border);justify-content:space-between;align-items:center;display:none}.page-title{margin-bottom:calc(var(--spacing) * 4);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900)}.session-id{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-500);font-family:var(--font-mono)}.metadata-url{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.window-switcher{align-items:center;gap:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);display:flex}.window-switcher-label{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);white-space:nowrap;color:var(--color-gray-400);text-transform:uppercase}.window-overflow{display:inline-block;position:relative}.window-overflow summary{cursor:pointer;list-style-type:none}.window-overflow summary::-webkit-details-marker{display:none}.window-overflow-menu{top:100%;left:calc(var(--spacing) * 0);z-index:10;margin-top:calc(var(--spacing) * 1);border-radius:var(--radius-lg);background-color:var(--color-white);min-width:200px;padding:calc(var(--spacing) * 1);border:1px solid var(--color-border);position:absolute;box-shadow:0 8px 24px #0000001f,0 2px 6px #0000000a}.window-overflow-menu a{border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-600);text-decoration-line:none;display:block}.window-overflow-menu a:hover{background-color:var(--color-gray-50)}.window-overflow-menu a.active{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);background:var(--color-primary-light);color:var(--color-primary)}.player-frame{margin-top:calc(var(--spacing) * 2);border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);overflow:hidden;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.player-frame .rr-player{--tw-shadow:0 0 #0000;max-width:100%;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-radius:0}.player-frame .rr-controller{background:var(--color-surface-subtle);border-top:1px solid var(--color-border-light);border-radius:0}#replayer{justify-content:center;display:flex;overflow:hidden}.click-overlay-container{pointer-events:none;inset:calc(var(--spacing) * 0);z-index:20;position:absolute}.click-dot{pointer-events:none;background:#dc35458c;border:1px solid #dc3545;border-radius:3.40282e38px;width:12px;height:12px;transition:transform .12s,background .12s;position:absolute;box-shadow:0 0 0 2px #dc354533}.click-dot:hover,.click-dot.active{background:#dc3545d9;transform:scale(1.4)}#event-markers{padding-inline:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 2)}.marker-legend{margin-bottom:calc(var(--spacing) * 1);gap:calc(var(--spacing) * 3);color:var(--color-gray-400);font-size:10px;display:flex}.marker-legend-item{align-items:center;gap:calc(var(--spacing) * 1);display:flex}.marker-legend-dot{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;display:inline-block}.marker-bar{margin-bottom:calc(var(--spacing) * 2);height:calc(var(--spacing) * 8);cursor:pointer;border-radius:var(--radius-md);background:var(--color-surface-subtle);border:1px solid var(--color-border);box-sizing:border-box;max-width:100%;position:relative;overflow:visible}.marker-track{pointer-events:none;right:calc(var(--spacing) * 2);left:calc(var(--spacing) * 2);background:var(--color-border);border-radius:3.40282e38px;height:2px;position:absolute;top:50%;transform:translateY(-50%)}.marker-playhead{pointer-events:none;top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);z-index:10;background:var(--color-primary);border-radius:1px;width:2px;transition:left .15s linear;position:absolute;box-shadow:0 0 4px #2563eb4d}.marker-playhead:before{content:"";background:var(--color-primary);border-radius:50%;width:8px;height:8px;display:block;position:absolute;top:-3px;left:50%;transform:translate(-50%);box-shadow:0 0 0 2px #fff,0 1px 3px #00000026}.event-marker{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);cursor:pointer;opacity:.7;border-radius:3.40282e38px;transition:opacity .12s,transform .12s;position:absolute;top:50%;transform:translate(-50%,-50%)}.event-marker:hover{z-index:1;opacity:1;transform:translate(-50%,-50%)scale(1.6)}.event-marker.error-marker{z-index:2;height:calc(var(--spacing) * 2.5);width:calc(var(--spacing) * 2.5);opacity:1;box-shadow:0 0 4px #dc262666}.marker-gap{top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);z-index:0;cursor:help;border-left:1px solid var(--color-gray-300);border-right:1px solid var(--color-gray-300);background:repeating-linear-gradient(-45deg, transparent, transparent 3px, var(--color-border) 3px, var(--color-border) 5px);position:absolute}.marker-group{z-index:3;cursor:pointer;position:absolute;top:50%;transform:translate(-50%,-50%)}.marker-group .event-marker{display:block;position:static;transform:none}.marker-group:hover .event-marker{transform:scale(1.4)}.marker-group-dot{opacity:.9;width:14px!important;height:14px!important}.marker-group-count{pointer-events:none;background-color:var(--color-gray-700);text-align:center;--tw-font-weight:var(--font-weight-bold);font-size:9px;font-weight:var(--font-weight-bold);color:var(--color-white);border-radius:3.40282e38px;min-width:14px;padding-inline:3px;line-height:14px;position:absolute;top:-8px;left:50%;transform:translate(-50%)}.marker-dropdown{z-index:50;max-width:var(--container-xs);border-radius:var(--radius-lg);background-color:var(--color-white);min-width:200px;padding:calc(var(--spacing) * 1);white-space:nowrap;border:1px solid var(--color-border);position:absolute;top:22px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #0000001f,0 2px 6px #0000000a}.marker-dropdown-item{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);color:var(--color-gray-600);font-size:11px;display:flex}.marker-dropdown-item:hover{background-color:var(--color-gray-50)}.marker-dropdown-item.error-entry{background:var(--color-danger-bg)}.marker-dropdown-item.error-entry:hover{background:#fee2e2}#activity-sidebar{margin-top:calc(var(--spacing) * 2);border-radius:var(--radius-lg);background-color:var(--color-white);max-height:600px;padding:calc(var(--spacing) * 3);border:1px solid var(--color-border);overflow-y:auto;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}#activity-sidebar h6{margin-bottom:calc(var(--spacing) * 2);align-items:center;gap:calc(var(--spacing) * 2);display:flex}.activity-entry{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-bottom:1px solid var(--color-border-light);transition:background 80ms;display:flex}.activity-entry:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.activity-entry:hover{background-color:var(--color-gray-50)}.activity-entry.active{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);background:var(--color-primary-light)}.activity-entry.error-entry{background:var(--color-danger-bg);border-left:3px solid var(--color-danger)}.activity-entry.navigation-entry{background:var(--color-success-bg);border-left:3px solid var(--color-success)}.activity-entry.frustration-entry{background:#ff6b6b1a;border-left:3px solid #ff6b6b}.activity-entry.server-exception-entry{background:#b91c1c14;border-left:3px solid #b91c1c}.activity-entry.server-event-entry{background:#7c3aed14;border-left:3px solid #7c3aed}.activity-label-link{color:var(--color-blue-600);text-decoration-line:none}@media (hover:hover){.activity-label-link:hover{text-decoration-line:underline}}.activity-time{min-width:calc(var(--spacing) * 10);white-space:nowrap;color:var(--color-gray-400);font-size:10px;font-family:var(--font-mono)}.activity-dot{height:calc(var(--spacing) * 1.5);width:calc(var(--spacing) * 1.5);border-radius:3.40282e38px;flex-shrink:0;display:inline-block}.activity-label{text-overflow:ellipsis;white-space:nowrap;color:var(--color-gray-600);overflow:hidden}.activity-gap{margin-block:calc(var(--spacing) * .5);padding-block:calc(var(--spacing) * .5);text-align:center;color:var(--color-gray-400);border-top:1px dashed var(--color-border-light);border-bottom:1px dashed var(--color-border-light);background:repeating-linear-gradient(-45deg, transparent, transparent 4px, var(--color-surface-subtle) 4px, var(--color-surface-subtle) 6px);font-size:10px}.activity-wrapper{position:relative}.activity-detail{margin-left:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);overflow-wrap:break-word;color:var(--color-gray-500);background:var(--color-surface-subtle);border-left:2px solid var(--color-border);border-radius:0 6px 6px 0;font-size:11px;display:none}.activity-wrapper:hover .activity-detail{display:block}.activity-detail-line{margin-bottom:calc(var(--spacing) * .5)}.activity-detail-line:last-child{margin-bottom:calc(var(--spacing) * 0)}.activity-detail-pre{margin-top:calc(var(--spacing) * .5);background-color:var(--color-gray-100);max-height:120px;padding:calc(var(--spacing) * 1.5);word-break:break-all;white-space:pre-wrap;font-size:10px;font-family:var(--font-mono);border-radius:.25rem;overflow-y:auto}.stats-chart{margin-top:calc(var(--spacing) * 3);align-items:flex-end;gap:calc(var(--spacing) * 1);height:180px;padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 2);border-bottom:1px solid var(--color-border-light);display:flex}.stats-chart-col{min-width:calc(var(--spacing) * 0);flex-direction:column;flex:1;align-items:center;display:flex}.stats-chart-bar-wrapper{flex:1;justify-content:center;align-items:flex-end;width:100%;height:140px;display:flex}.stats-chart-bar{background:var(--color-primary);border-radius:4px 4px 0 0;width:60%;max-width:36px;min-height:2px;transition:height .3s,background .12s;position:relative}.stats-chart-bar:hover{background:var(--color-primary-hover)}.stats-chart-value{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);white-space:nowrap;color:var(--color-gray-500);position:absolute;top:-16px;left:50%;transform:translate(-50%)}.stats-chart-label{margin-top:calc(var(--spacing) * 1);white-space:nowrap;color:var(--color-gray-400);font-size:10px}@media (max-width:768px){.s-sidebar{display:none}.s-mobile-header{display:flex}.s-main{margin-left:calc(var(--spacing) * 0);padding:calc(var(--spacing) * 4)}}details[open]>summary .group-open\:rotate-90{transform:rotate(90deg)}@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-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@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}@property --tw-leading{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-outline-style{syntax:"*";inherits:false;initial-value:solid}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_app"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Web
|
|
7
|
+
# Standalone Rack endpoint serving the gem's static assets (mounted via
|
|
8
|
+
# r.sentiero_assets). Shares #serve with BaseApp#handle_asset so the dashboard's
|
|
9
|
+
# /assets/* route applies the same traversal guard, content types, and caching.
|
|
10
|
+
class AssetsApp < BaseApp
|
|
11
|
+
def call(env)
|
|
12
|
+
return [405, {"content-type" => "text/plain"}, ["Method Not Allowed"]] unless env["REQUEST_METHOD"] == "GET"
|
|
13
|
+
|
|
14
|
+
serve(env["PATH_INFO"].delete_prefix("/"))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolved path must stay inside ASSETS_DIR (blocks traversal/absolute
|
|
18
|
+
# paths); .erb files are never served raw. Fingerprinted basenames
|
|
19
|
+
# (name-HASH.ext) get a year of immutable cache (31536000s), else one day.
|
|
20
|
+
def serve(relative_path)
|
|
21
|
+
return not_found if relative_path.nil? || relative_path.empty?
|
|
22
|
+
|
|
23
|
+
full_path = File.expand_path(relative_path, ASSETS_DIR)
|
|
24
|
+
|
|
25
|
+
return not_found unless full_path.start_with?(ASSETS_DIR + File::SEPARATOR)
|
|
26
|
+
return not_found if full_path.end_with?(".erb")
|
|
27
|
+
return not_found unless File.file?(full_path)
|
|
28
|
+
|
|
29
|
+
ext = File.extname(full_path)
|
|
30
|
+
content_type = CONTENT_TYPES.fetch(ext, "application/octet-stream")
|
|
31
|
+
|
|
32
|
+
cache_control = if File.basename(full_path).match?(/\A[^\/]+-[A-Za-z0-9]+\.\w+\z/)
|
|
33
|
+
"public, max-age=31536000, immutable"
|
|
34
|
+
else
|
|
35
|
+
"public, max-age=86400"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[200, {"content-type" => content_type, "cache-control" => cache_control}, [File.binread(full_path)]]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
require_relative "escaping"
|
|
8
|
+
require_relative "formatting"
|
|
9
|
+
require_relative "views"
|
|
10
|
+
require_relative "basic_auth_check"
|
|
11
|
+
require_relative "../store"
|
|
12
|
+
require_relative "../user_agent"
|
|
13
|
+
require_relative "../ip_anonymizer"
|
|
14
|
+
|
|
15
|
+
module Sentiero
|
|
16
|
+
module Web
|
|
17
|
+
# Shared, non-routing machinery for the dashboard UI Rack apps (DashboardApp,
|
|
18
|
+
# AnalyticsApp, MonitoringApp): auth, CSRF, escaping, security headers, asset
|
|
19
|
+
# serving, the routing combinators, and the render_page view-rendering entry
|
|
20
|
+
# point. Subclasses implement their own #call routing.
|
|
21
|
+
class BaseApp
|
|
22
|
+
include Escaping
|
|
23
|
+
include Formatting
|
|
24
|
+
|
|
25
|
+
ASSETS_DIR = File.expand_path("assets", __dir__).freeze
|
|
26
|
+
|
|
27
|
+
CSP_POLICY = [
|
|
28
|
+
"default-src 'self'",
|
|
29
|
+
"script-src 'self'",
|
|
30
|
+
"style-src 'self' 'unsafe-inline'",
|
|
31
|
+
"img-src 'self' data:",
|
|
32
|
+
"frame-src 'self' blob:"
|
|
33
|
+
].join("; ").freeze
|
|
34
|
+
|
|
35
|
+
CONTENT_TYPES = {
|
|
36
|
+
".css" => "text/css",
|
|
37
|
+
".js" => "application/javascript",
|
|
38
|
+
".html" => "text/html",
|
|
39
|
+
".png" => "image/png",
|
|
40
|
+
".svg" => "image/svg+xml"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Warn at most once per process: the Roda plugin and /analytics delegation
|
|
44
|
+
# construct apps per request, so a per-construction warning would spam.
|
|
45
|
+
AUTH_WARNING_LOCK = Mutex.new
|
|
46
|
+
@auth_warning_emitted = false
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# With no auth configured the dashboard fails closed (403) unless
|
|
50
|
+
# allow_insecure_dashboard is set, in which case it serves unauthenticated
|
|
51
|
+
# and we warn once. Called on BaseApp so both subclasses share one flag
|
|
52
|
+
# (class ivars aren't inherited).
|
|
53
|
+
def warn_unauthenticated_once
|
|
54
|
+
config = Sentiero.configuration
|
|
55
|
+
return if config.basic_auth || config.auth_callback
|
|
56
|
+
return unless config.allow_insecure_dashboard
|
|
57
|
+
|
|
58
|
+
AUTH_WARNING_LOCK.synchronize do
|
|
59
|
+
return if @auth_warning_emitted
|
|
60
|
+
@auth_warning_emitted = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
warn "[Sentiero] dashboard mounted with allow_insecure_dashboard and no " \
|
|
64
|
+
"authentication (config.basic_auth and config.auth_callback both unset); " \
|
|
65
|
+
"session recordings and analytics are publicly accessible to anyone who " \
|
|
66
|
+
"can reach this mount. Set config.basic_auth or config.auth_callback to " \
|
|
67
|
+
"protect it."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reset_auth_warning!
|
|
71
|
+
@auth_warning_emitted = false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def authorized?(env)
|
|
78
|
+
creds = Sentiero.configuration.basic_auth
|
|
79
|
+
return basic_auth_authorized?(env, creds) unless creds.nil?
|
|
80
|
+
|
|
81
|
+
callback = Sentiero.configuration.auth_callback
|
|
82
|
+
# No auth configured: fail closed unless explicitly opted out.
|
|
83
|
+
return Sentiero.configuration.allow_insecure_dashboard if callback.nil?
|
|
84
|
+
!!callback.call(env)
|
|
85
|
+
rescue Sentiero::Error
|
|
86
|
+
raise
|
|
87
|
+
rescue => e
|
|
88
|
+
warn "[Sentiero] auth_callback raised #{e.class}: #{e.message}"
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def basic_auth_authorized?(env, creds)
|
|
93
|
+
if BasicAuthCheck.credentials_blank?(creds)
|
|
94
|
+
raise Sentiero::Error,
|
|
95
|
+
"config.basic_auth is set but the username or password is blank. " \
|
|
96
|
+
"Set SENTIERO_DASHBOARD_PASSWORD (or remove config.basic_auth to " \
|
|
97
|
+
"disable dashboard auth)."
|
|
98
|
+
end
|
|
99
|
+
BasicAuthCheck.authorized?(env, creds)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def audit!(env, action:, session_id: nil, window_id: nil, dataset: nil, problem_id: nil)
|
|
103
|
+
callback = Sentiero.configuration.audit_log
|
|
104
|
+
return if callback.nil?
|
|
105
|
+
|
|
106
|
+
callback.call(
|
|
107
|
+
action: action,
|
|
108
|
+
session_id: session_id,
|
|
109
|
+
window_id: window_id,
|
|
110
|
+
dataset: dataset,
|
|
111
|
+
problem_id: problem_id,
|
|
112
|
+
user: audit_user(env),
|
|
113
|
+
ip: audit_ip(env),
|
|
114
|
+
timestamp: Time.now,
|
|
115
|
+
path: env["PATH_INFO"]
|
|
116
|
+
)
|
|
117
|
+
rescue => e
|
|
118
|
+
warn "[Sentiero] audit_log raised #{e.class}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def audit_user(env)
|
|
122
|
+
env["sentiero.user"] || env["REMOTE_USER"]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def audit_ip(env)
|
|
126
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip
|
|
127
|
+
ip = (forwarded && !forwarded.empty?) ? forwarded : env["REMOTE_ADDR"]
|
|
128
|
+
Sentiero.configuration.anonymize_ip ? IpAnonymizer.anonymize(ip) : ip
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def forbidden
|
|
132
|
+
[403, {"content-type" => "text/plain"}, ["Forbidden"]]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def unauthorized_response
|
|
136
|
+
if Sentiero.configuration.basic_auth.nil?
|
|
137
|
+
forbidden
|
|
138
|
+
else
|
|
139
|
+
[401,
|
|
140
|
+
{"content-type" => "text/plain", "www-authenticate" => 'Basic realm="Sentiero"'},
|
|
141
|
+
["Unauthorized"]]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def not_found
|
|
146
|
+
[404, {"content-type" => "text/plain"}, ["Not Found"]]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def redirect(location, status: 302)
|
|
150
|
+
[status, {"location" => location}, []]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Mount prefix (empty for a root mount); prepended to redirect targets.
|
|
154
|
+
def base_path(env)
|
|
155
|
+
env["SCRIPT_NAME"] || ""
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def invalid_id
|
|
159
|
+
[400, json_headers, ['{"error":"invalid ID format"}']]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def forbidden_csrf
|
|
163
|
+
[403, {"content-type" => "text/plain"}, ["Invalid CSRF token"]]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Combinator: returns a 403 to short-circuit a mutating route when the CSRF
|
|
167
|
+
# token is missing/invalid, or nil to fall through (`verify_csrf(env) || …`).
|
|
168
|
+
def verify_csrf(env)
|
|
169
|
+
forbidden_csrf unless valid_csrf_token?(env, Rack::Request.new(env).POST["csrf_token"])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def valid_id?(id)
|
|
173
|
+
id.is_a?(String) && id.match?(Store::VALID_ID)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def html_headers(extra = {})
|
|
177
|
+
{
|
|
178
|
+
"content-type" => "text/html",
|
|
179
|
+
"content-security-policy" => CSP_POLICY,
|
|
180
|
+
"x-content-type-options" => "nosniff",
|
|
181
|
+
"x-frame-options" => "DENY"
|
|
182
|
+
}.merge(extra)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def json_headers(extra = {})
|
|
186
|
+
{"content-type" => "application/json", "x-content-type-options" => "nosniff"}.merge(extra)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def generate_csrf_token
|
|
190
|
+
SecureRandom.hex(32)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def csrf_cookie_header(env, csrf_token, base_path)
|
|
194
|
+
cookie = "sentiero_csrf=#{csrf_token}; HttpOnly; SameSite=Strict; Path=#{base_path}/"
|
|
195
|
+
cookie += "; Secure" if env["rack.url_scheme"] == "https"
|
|
196
|
+
cookie
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def valid_csrf_token?(env, token)
|
|
200
|
+
expected = Rack::Utils.parse_cookies(env)["sentiero_csrf"]
|
|
201
|
+
return false if expected.nil? || expected.empty?
|
|
202
|
+
return false if token.nil? || token.empty?
|
|
203
|
+
Rack::Utils.secure_compare(expected, token)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Standard `since`/`until` range params (ISO date or ISO-8601 timestamp),
|
|
207
|
+
# parsed in UTC. Zone-less timestamps assume UTC; invalid input = no filter.
|
|
208
|
+
DATE_ONLY_FORMAT = /\A\d{4}-\d{2}-\d{2}\z/
|
|
209
|
+
END_OF_DAY_SECONDS = 86_399.999
|
|
210
|
+
|
|
211
|
+
def parse_range_params(params)
|
|
212
|
+
[parse_since_param(params["since"]), parse_until_param(params["until"])]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_since_param(value)
|
|
216
|
+
parse_utc_time_param(value, end_of_day: false)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def parse_until_param(value)
|
|
220
|
+
parse_utc_time_param(value, end_of_day: true)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_utc_time_param(value, end_of_day:)
|
|
224
|
+
return nil if value.nil? || value.empty?
|
|
225
|
+
|
|
226
|
+
if DATE_ONLY_FORMAT.match?(value)
|
|
227
|
+
base = Time.utc(*value.split("-").map(&:to_i)).to_f
|
|
228
|
+
end_of_day ? base + END_OF_DAY_SECONDS : base
|
|
229
|
+
elsif value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/i)
|
|
230
|
+
Time.parse(value).to_f
|
|
231
|
+
else
|
|
232
|
+
Time.parse("#{value} UTC").to_f
|
|
233
|
+
end
|
|
234
|
+
rescue ArgumentError, TypeError, RangeError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def echo_range_params(params, since, until_time)
|
|
239
|
+
[since ? params["since"].to_s : "", until_time ? params["until"].to_s : ""]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Ceiling on the requested page
|
|
243
|
+
MAX_PAGE = 10_000
|
|
244
|
+
|
|
245
|
+
def clamp_page(value)
|
|
246
|
+
page = value.to_i
|
|
247
|
+
page = 1 if page < 1
|
|
248
|
+
[page, MAX_PAGE].min
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def clamp_per_page(value, default:, max:)
|
|
252
|
+
per_page = value.to_i
|
|
253
|
+
per_page = default if per_page < 1
|
|
254
|
+
[per_page, max].min
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def query_params(env)
|
|
258
|
+
Rack::Utils.parse_query(env["QUERY_STRING"] || "")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Clamped [page, per_page, offset] from the request params.
|
|
262
|
+
def paginate(params, default:, max:)
|
|
263
|
+
page = clamp_page(params["page"])
|
|
264
|
+
per_page = clamp_per_page(params["per_page"], default: default, max: max)
|
|
265
|
+
[page, per_page, (page - 1) * per_page]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Callers obtain per_page + 1 rows; the extra row signals that another page exists
|
|
269
|
+
def take_page(rows, per_page)
|
|
270
|
+
[rows.first(per_page), rows.size > per_page]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Router combinators: each returns an early-exit Rack response, or nil to
|
|
274
|
+
# fall through, so a route arm reads
|
|
275
|
+
# `get_only(method) || guard(id) || handle_x(env, id)`.
|
|
276
|
+
|
|
277
|
+
def get_only(method)
|
|
278
|
+
not_found unless method == "GET"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def post_only(method)
|
|
282
|
+
not_found unless method == "POST"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# 400 unless every captured path id is well-formed, so malformed ids are
|
|
286
|
+
# rejected before they reach a handler or a store query.
|
|
287
|
+
def guard(*ids)
|
|
288
|
+
invalid_id unless ids.all? { |id| valid_id?(id) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def delete_request?(method, env)
|
|
292
|
+
return true if method == "DELETE"
|
|
293
|
+
|
|
294
|
+
method == "POST" && query_params(env)["_method"] == "delete"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def handle_asset(relative_path)
|
|
298
|
+
require_relative "assets_app"
|
|
299
|
+
AssetsApp.new.serve(relative_path)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# With csrf: true a CSRF token is minted, set as the sentiero_csrf cookie,
|
|
303
|
+
# and injected into the view as csrf_token.
|
|
304
|
+
def render_page(env, view, csrf: true, request_path: nil)
|
|
305
|
+
view.base_path = base_path(env)
|
|
306
|
+
request_path ||= env["PATH_INFO"] || "/"
|
|
307
|
+
headers = if csrf
|
|
308
|
+
token = generate_csrf_token
|
|
309
|
+
view.csrf_token = token
|
|
310
|
+
html_headers("set-cookie" => csrf_cookie_header(env, token, view.base_path))
|
|
311
|
+
else
|
|
312
|
+
html_headers
|
|
313
|
+
end
|
|
314
|
+
body = view.render_layout(view.render, request_path: request_path)
|
|
315
|
+
[200, headers, [body]]
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
require_relative "basic_auth_check"
|
|
5
|
+
|
|
6
|
+
module Sentiero
|
|
7
|
+
module Web
|
|
8
|
+
# Optional HTTP Basic auth middleware for standalone dashboards. Passes
|
|
9
|
+
# through when basic_auth is unset; blank configured credentials lock
|
|
10
|
+
# everyone out (401). Assumes TLS upstream.
|
|
11
|
+
class BasicAuth
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
creds = Sentiero.configuration.basic_auth
|
|
18
|
+
return @app.call(env) if creds.nil?
|
|
19
|
+
return @app.call(env) if BasicAuthCheck.authorized?(env, creds)
|
|
20
|
+
|
|
21
|
+
[401,
|
|
22
|
+
{"content-type" => "text/plain", "www-authenticate" => 'Basic realm="Sentiero"'},
|
|
23
|
+
["Unauthorized"]]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Web
|
|
7
|
+
# Shared HTTP Basic credential checking. Constant-time comparison;
|
|
8
|
+
# assumes TLS terminated upstream.
|
|
9
|
+
module BasicAuthCheck
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def credentials_blank?(creds)
|
|
13
|
+
creds[:user].to_s.empty? || creds[:password].to_s.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def authorized?(env, creds)
|
|
17
|
+
return false if credentials_blank?(creds)
|
|
18
|
+
|
|
19
|
+
header = env["HTTP_AUTHORIZATION"]
|
|
20
|
+
return false unless header
|
|
21
|
+
|
|
22
|
+
scheme, encoded = header.split(" ", 2)
|
|
23
|
+
return false unless scheme&.downcase == "basic" && encoded
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
# Strict base64 decode: "m0" raises ArgumentError on invalid input.
|
|
27
|
+
decoded = encoded.unpack1("m0")
|
|
28
|
+
rescue ArgumentError
|
|
29
|
+
return false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
user, password = decoded.split(":", 2)
|
|
33
|
+
return false if user.nil? || password.nil?
|
|
34
|
+
|
|
35
|
+
user_ok = Rack::Utils.secure_compare(user, creds[:user].to_s)
|
|
36
|
+
pass_ok = Rack::Utils.secure_compare(password, creds[:password].to_s)
|
|
37
|
+
user_ok && pass_ok
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module Sentiero
|
|
7
|
+
module Web
|
|
8
|
+
# Reads a request body (optionally gzip-encoded) with a hard 512KB cap on
|
|
9
|
+
# BOTH the compressed and decompressed size, so a gzip bomb can't blow past
|
|
10
|
+
# it. Shared by the two untrusted-input lanes (EventsApp, IngestApp), which
|
|
11
|
+
# differ only in the response they build from the error symbol.
|
|
12
|
+
module BodyReader
|
|
13
|
+
MAX_BODY_SIZE = 524_288 # 512 KB
|
|
14
|
+
|
|
15
|
+
# error symbol => [http_status, message]
|
|
16
|
+
ERRORS = {
|
|
17
|
+
too_large: [413, "request body too large"],
|
|
18
|
+
bad_gzip: [400, "invalid gzip encoding"]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# [body, nil] on success, or [nil, :too_large | :bad_gzip] on failure.
|
|
24
|
+
def read(env)
|
|
25
|
+
raw = env["rack.input"].read(MAX_BODY_SIZE + 1) || ""
|
|
26
|
+
env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
|
|
27
|
+
return [nil, :too_large] if raw.bytesize > MAX_BODY_SIZE
|
|
28
|
+
|
|
29
|
+
if env["HTTP_CONTENT_ENCODING"]&.downcase == "gzip"
|
|
30
|
+
begin
|
|
31
|
+
gz = Zlib::GzipReader.new(StringIO.new(raw))
|
|
32
|
+
raw = gz.read(MAX_BODY_SIZE + 1) || ""
|
|
33
|
+
gz.close
|
|
34
|
+
rescue Zlib::GzipFile::Error
|
|
35
|
+
return [nil, :bad_gzip]
|
|
36
|
+
end
|
|
37
|
+
return [nil, :too_large] if raw.bytesize > MAX_BODY_SIZE
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
[raw, nil]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
module Web
|
|
5
|
+
# Minimal RFC 4180 CSV serializer with spreadsheet-formula-injection guarding:
|
|
6
|
+
# cells starting with a formula trigger are prefixed with a single quote so the
|
|
7
|
+
# spreadsheet treats them as text rather than executing them.
|
|
8
|
+
module CsvWriter
|
|
9
|
+
# Tab/CR included: some spreadsheets strip them before re-evaluating the cell.
|
|
10
|
+
FORMULA_TRIGGERS = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def generate(headers, rows)
|
|
15
|
+
([headers] + rows).map { |row| format_row(row) }.join("\r\n") + "\r\n"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def format_row(row)
|
|
19
|
+
row.map { |cell| format_cell(cell) }.join(",")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_cell(cell)
|
|
23
|
+
quote(guard_injection(stringify(cell)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stringify(cell)
|
|
27
|
+
case cell
|
|
28
|
+
when nil then ""
|
|
29
|
+
when true then "true"
|
|
30
|
+
when false then "false"
|
|
31
|
+
else cell.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def guard_injection(value)
|
|
36
|
+
value.start_with?(*FORMULA_TRIGGERS) ? "'#{value}" : value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def quote(value)
|
|
40
|
+
return value unless value.match?(/[",\r\n]/)
|
|
41
|
+
%("#{value.gsub('"', '""')}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|