From 91fc06188f0b06d72a35ad1be364c04e41fbb1bc Mon Sep 17 00:00:00 2001 From: Madison Date: Thu, 28 May 2026 10:43:06 -0500 Subject: [PATCH] better cron jobs. Better UI on walkthrough. --- apps/backend/scripts/run-cron-jobs.ts | 126 ++++++++++++---- apps/dashboard/src/app/globals.css | 11 ++ .../components/walkthrough/mock-cursor.tsx | 57 ++++--- .../walkthrough/walkthrough-overlay.tsx | 139 +++++++++++------- .../walkthrough/walkthrough-steps.ts | 16 +- 5 files changed, 242 insertions(+), 107 deletions(-) diff --git a/apps/backend/scripts/run-cron-jobs.ts b/apps/backend/scripts/run-cron-jobs.ts index 272e7b6e0..33b3653cd 100644 --- a/apps/backend/scripts/run-cron-jobs.ts +++ b/apps/backend/scripts/run-cron-jobs.ts @@ -8,9 +8,105 @@ const endpoints = [ "/api/latest/internal/external-db-sync/poller", ]; -const previewEndpoints = [ - "/api/latest/internal/preview/fill-pool", -]; +const previewFillPoolEndpoint = "/api/latest/internal/preview/fill-pool"; + +const PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS = 5_000; +const PREVIEW_FILL_POOL_IDLE_INTERVAL_MS = 60_000; +const PREVIEW_FILL_POOL_ERROR_INTERVAL_MS = 10_000; +const BACKEND_HEALTH_POLL_INTERVAL_MS = 2_000; + +type PreviewFillPoolResult = { + ready_count_before: number, + created_count: number, + target_ready_count: number, + deleted_expired_count: number, +}; + +async function waitUntilBackendReady(baseUrl: string): Promise { + while (true) { + const healthResult = await Result.fromPromise(fetch(`${baseUrl}/health`)); + if (healthResult.status === "ok" && healthResult.data.ok) { + return; + } + await wait(BACKEND_HEALTH_POLL_INTERVAL_MS); + } +} + +function getPreviewFillPoolPollIntervalMs(result: PreviewFillPoolResult): number { + const poolNeedsFilling = result.ready_count_before < result.target_ready_count; + const didWork = result.created_count > 0 || result.deleted_expired_count > 0; + if (poolNeedsFilling || didWork) { + return PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS; + } + return PREVIEW_FILL_POOL_IDLE_INTERVAL_MS; +} + +function logPreviewFillPoolActivity(result: PreviewFillPoolResult): void { + const parts: string[] = []; + if (result.created_count > 0) { + parts.push(`created ${result.created_count}`); + } + if (result.deleted_expired_count > 0) { + parts.push(`cleaned up ${result.deleted_expired_count} expired lease(s)`); + } + if (parts.length === 0) { + return; + } + + const readyCount = result.ready_count_before + result.created_count; + console.log(`Preview pool: ${parts.join(", ")} (${readyCount}/${result.target_ready_count} ready)`); +} + +async function runPreviewFillPool(baseUrl: string, cronSecret: string): Promise { + const res = await fetch(`${baseUrl}${previewFillPoolEndpoint}`, { + headers: { + "Authorization": `Bearer ${cronSecret}`, + "x-stack-development-disable-extended-logging": "yes", + }, + }); + if (!res.ok) { + throw new HexclaveAssertionError( + `Failed to call ${previewFillPoolEndpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, + { res }, + ); + } + return await res.json() as PreviewFillPoolResult; +} + +async function runPreviewFillPoolLoop(baseUrl: string, cronSecret: string): Promise { + console.log("Preview pool cron started (fills every 5s while below target, every 60s when full)."); + await waitUntilBackendReady(baseUrl); + + let isIdle = false; + while (true) { + const runResult = await Result.fromPromise(runPreviewFillPool(baseUrl, cronSecret)); + if (runResult.status === "error") { + captureError("run-cron-jobs-preview", runResult.error); + isIdle = false; + await wait(PREVIEW_FILL_POOL_ERROR_INTERVAL_MS); + continue; + } + + const result = runResult.data; + const didWork = result.created_count > 0 || result.deleted_expired_count > 0; + const poolNeedsFilling = result.ready_count_before < result.target_ready_count; + + if (didWork) { + logPreviewFillPoolActivity(result); + isIdle = false; + } else if (poolNeedsFilling) { + isIdle = false; + } else if (!isIdle) { + console.log( + `Preview pool full (${result.ready_count_before}/${result.target_ready_count} ready), ` + + `polling every ${PREVIEW_FILL_POOL_IDLE_INTERVAL_MS / 1000}s`, + ); + isIdle = true; + } + + await wait(getPreviewFillPoolPollIntervalMs(result)); + } +} async function main() { const baseUrl = `http://localhost:${getEnvVariable('NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX', '81')}02`; @@ -25,27 +121,7 @@ async function main() { return; } - const run = async (endpoint: string) => { - console.log(`Running ${endpoint}...`); - const res = await fetch(`${baseUrl}${endpoint}`, { - headers: { 'Authorization': `Bearer ${cronSecret}` }, - }); - if (!res.ok) throw new HexclaveAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res }); - console.log(`${endpoint} completed.`); - }; - - for (const endpoint of previewEndpoints) { - runAsynchronously(async () => { - await wait(30_000); // Wait 30 seconds to make sure the server is fully started - while (true) { - const runResult = await Result.fromPromise(run(endpoint)); - if (runResult.status === "error") { - captureError("run-cron-jobs-preview", runResult.error); - } - await wait(5000); - } - }); - } + runAsynchronously(() => runPreviewFillPoolLoop(baseUrl, cronSecret)); return; } console.log("Starting cron jobs..."); @@ -62,7 +138,7 @@ async function main() { for (const endpoint of endpoints) { runAsynchronously(async () => { - await wait(30_000); // Wait 30 seconds to make sure the server is fully started + await waitUntilBackendReady(baseUrl); while (true) { const runResult = await Result.fromPromise(run(endpoint)); if (runResult.status === "error") { diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index f5d402157..14be04474 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -414,6 +414,17 @@ body:has(.show-site-loading-indicator) .site-loading-indicator { } } +@keyframes walkthrough-pulse-ring { + 0% { + transform: scale(0.85); + opacity: 0.9; + } + 100% { + transform: scale(1.6); + opacity: 0; + } +} + /* Pacifica styles */ [data-pacifica-surface] { diff --git a/apps/dashboard/src/components/walkthrough/mock-cursor.tsx b/apps/dashboard/src/components/walkthrough/mock-cursor.tsx index 022d3e87f..6e33c23d5 100644 --- a/apps/dashboard/src/components/walkthrough/mock-cursor.tsx +++ b/apps/dashboard/src/components/walkthrough/mock-cursor.tsx @@ -1,21 +1,42 @@ -export function MockCursor({ className }: { className?: string }) { +import { cn } from '@/lib/utils'; +import type { WalkthroughPhase } from './walkthrough-steps'; + +export function MockCursor({ + className, + phase, +}: { + className?: string, + phase?: WalkthroughPhase, +}) { return ( - - - +
+ {phase === 'dwelling' && ( + + )} + + + +
); } diff --git a/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx b/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx index 185960c74..a60eec3a2 100644 --- a/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx +++ b/apps/dashboard/src/components/walkthrough/walkthrough-overlay.tsx @@ -4,9 +4,11 @@ import { cn } from '@/lib/utils'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { MockCursor } from './mock-cursor'; -import { type SpotlightRect, type WalkthroughStep } from './walkthrough-steps'; +import { type SpotlightRect, type WalkthroughPhase, type WalkthroughStep } from './walkthrough-steps'; -export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing'; +export type { WalkthroughPhase }; + +const SPOTLIGHT_BORDER = 'rgb(59 130 246)'; // blue-500 export function WalkthroughOverlay({ step, @@ -61,7 +63,7 @@ export function WalkthroughOverlay({ : null; const padding = step?.spotlightPadding ?? 8; - const overlayOpacity = showSpotlight ? (animatedIn ? 0.55 : 0) : 0; + const overlayOpacity = showSpotlight ? (animatedIn ? 0.62 : 0) : 0; return createPortal( <> @@ -70,15 +72,18 @@ export function WalkthroughOverlay({ {showSpotlight && displayRect && step && ( <>
@@ -89,6 +94,7 @@ export function WalkthroughOverlay({ totalSteps={totalSteps} phase={phase} spotlightRect={spotlightRect!} + padding={padding} /> )} @@ -103,10 +109,9 @@ export function WalkthroughOverlay({ transition: 'transform 500ms cubic-bezier(0.22, 1, 0.36, 1)', }} > - +
- {/* Floating tour progress card — bottom-center, bold and unmissable */} {step && ( )} - {/* "Click to take control" hover overlay — above everything including CmdK */} + {/* "Click to take control" hover overlay */}
{ @@ -135,7 +140,7 @@ export function WalkthroughOverlay({

- {/* Invisible click catcher (active only when not hovering) — anywhere = stop */} + {/* Invisible click catcher (active only when not hovering) */} {!isHovering && (
{ @@ -185,8 +187,6 @@ function TourProgressCard({ }); }); } else { - // 'navigating' — hold at start of step with a short transition so loop - // resets ease in rather than snap. setTransitionMs(250); setProgress(stepIndex / totalSteps); } @@ -220,8 +220,7 @@ function TourProgressCard({ className={cn( "inline-flex items-center px-2 py-0.5 rounded-full", "text-[10px] font-bold uppercase tracking-wider", - "bg-gradient-to-r from-blue-500 to-indigo-500 text-white", - "shadow-[0_0_12px_rgba(59,130,246,0.6)]", + "bg-blue-500 text-white", )} > Tour @@ -240,7 +239,6 @@ function TourProgressCard({
- {/* The big bar */}
- {/* Bright leading edge highlight */} -
-
+ />
@@ -281,53 +270,53 @@ function SpotlightTooltip({ totalSteps, phase, spotlightRect, + padding, }: { step: WalkthroughStep, stepIndex: number, totalSteps: number, phase: WalkthroughPhase, spotlightRect: SpotlightRect, + padding: number, }) { - const tooltipWidth = 300; - const tooltipHeight = 96; - const tooltipGap = 16; - // Reserve room for the bottom progress card (~88px tall including margin). + const tooltipWidth = 280; + const tooltipGap = 14; const viewportMarginTop = 16; const viewportMarginBottom = 112; const viewportMarginX = 16; - const padding = step.spotlightPadding ?? 8; const spotlightTop = spotlightRect.top - padding; const spotlightBottom = spotlightRect.top + spotlightRect.height + padding; const spotlightCenterX = spotlightRect.left + spotlightRect.width / 2; - // Default: below the spotlight. + let placement: 'below' | 'above' = 'below'; let top = spotlightBottom + tooltipGap; - let left = spotlightCenterX - tooltipWidth / 2; - // If that would collide with the bottom progress card, flip to above the spotlight. - if (top + tooltipHeight > window.innerHeight - viewportMarginBottom) { - top = spotlightTop - tooltipGap - tooltipHeight; + const estimatedHeight = 96; + if (top + estimatedHeight > window.innerHeight - viewportMarginBottom) { + placement = 'above'; + top = spotlightTop - tooltipGap - estimatedHeight; } top = Math.max( viewportMarginTop, - Math.min(top, window.innerHeight - tooltipHeight - viewportMarginBottom), + Math.min(top, window.innerHeight - estimatedHeight - viewportMarginBottom), ); + + let left = spotlightCenterX - tooltipWidth / 2; left = Math.max( viewportMarginX, Math.min(left, window.innerWidth - tooltipWidth - viewportMarginX), ); + const pointerLeft = Math.max( + 20, + Math.min(spotlightCenterX - left, tooltipWidth - 20), + ); + return (
-
- {step.title} - - {stepIndex + 1} / {totalSteps} - + {/* Speech bubble pointer */} +
+ +
+
+ {step.title} + + {stepIndex + 1} / {totalSteps} + +
+

{step.description}

+ {phase === 'navigating' && ( +

navigating…

+ )}
-

{step.description}

- {phase === 'navigating' && ( -

navigating…

- )}
); } + diff --git a/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts b/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts index 8389c5018..01111fa6b 100644 --- a/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts +++ b/apps/dashboard/src/components/walkthrough/walkthrough-steps.ts @@ -1,3 +1,5 @@ +export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing'; + export type WalkthroughStep = { id: string, path: string, @@ -21,14 +23,14 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [ path: '/', sidebarNavLabel: 'Overview', title: 'Global User Map', - description: 'See where your users are around the world.', + description: 'Pinch-zoom around the globe and see where sign-ups are landing.', spotlightPadding: 12, }, { id: 'overview-metrics', path: '/', title: 'Usage Metrics', - description: 'Track daily active users and sign-ups at a glance.', + description: 'Daily actives, sign-ups, and retention — the numbers you actually check.', spotlightPadding: 12, }, { @@ -36,34 +38,34 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [ path: '/users', cmdkSearch: 'Users', title: 'User Management', - description: 'Manage all your users — search, export, or create new ones.', + description: 'Search, export, impersonate, or spin up test users in one place.', }, { id: 'teams-table', path: '/teams', cmdkSearch: 'Teams', title: 'Teams', - description: 'Organize users into teams for multi-tenant apps.', + description: 'Multi-tenant apps live here. Teams, roles, invites — the whole stack.', }, { id: 'emails-sent', path: '/email-sent', cmdkSearch: 'Emails sent', title: 'Email Logs', - description: 'Monitor sent emails, delivery status, and domain reputation.', + description: 'Every sent email, delivery status, and domain health in one feed.', }, { id: 'payments-products', path: '/payments/products', cmdkSearch: 'Products', title: 'Products & Pricing', - description: 'Define products, pricing, and subscriptions.', + description: 'Products, prices, and subscriptions wired straight to Stripe.', }, { id: 'analytics-replays', path: '/session-replays', sidebarNavLabel: 'Replays', title: 'Session Replays', - description: 'Watch real user sessions to understand how people use your app.', + description: 'Watch real sessions — clicks, rage taps, and dead ends included.', }, ];