Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ batch/ # CLI batch jobs for data processing
└── provider/ # GitHub API integration

db/
├── schema.sql # Declarative schema (Atlas source)
├── migrations/ # Atlas versioned migrations
├── shared.sql # Shared DB declarative schema (Atlas source)
├── tenant.sql # Per-org tenant DB declarative schema (Atlas source)
├── migrations/
│ ├── shared/ # Shared DB versioned migrations
│ └── tenant/ # Tenant DB versioned migrations
└── seed.ts # Seed data
```

Expand All @@ -109,7 +112,7 @@ Atlas + Kysely setup:
- **Kysely**: Runtime queries and type generation via kysely-codegen

```bash
# Generate new migration from schema.sql changes
# Generate new migration from shared.sql / tenant.sql changes
pnpm db:migrate

# Apply migrations to local database
Expand Down
284 changes: 167 additions & 117 deletions app/routes/$orgSlug/settings/data-management/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,10 @@ import {
Stack,
} from '~/app/components/ui'
import { Progress } from '~/app/components/ui/progress'
import { useTimezone } from '~/app/hooks/use-timezone'
import dayjs from '~/app/libs/dayjs'
import { orgContext } from '~/app/middleware/context'
import { durably } from '~/app/services/durably'
import { durably as serverDurably } from '~/app/services/durably.server'
import type { JobSteps } from '~/app/services/jobs/shared-steps.server'
import { getTenantDb } from '~/app/services/tenant-db.server'
import ContentSection from '../+components/content-section'
import type { Route } from './+types/index'

Expand All @@ -28,34 +25,28 @@ export const handle = {
}),
}

export const loader = async ({ context }: Route.LoaderArgs) => {
const { organization } = context.get(orgContext)

const tenantDb = getTenantDb(organization.id)
const organizationSetting = await tenantDb
.selectFrom('organizationSettings')
.select(['refreshRequestedAt'])
.executeTakeFirst()

return {
refreshRequestedAt: organizationSetting?.refreshRequestedAt ?? null,
}
}

export const action = async ({ request, context }: Route.ActionArgs) => {
const { organization: org } = context.get(orgContext)
const formData = await request.formData()
const intent = String(formData.get('intent'))

return match(intent)
.with('refresh', async () => {
const tenantDb = getTenantDb(org.id)
await tenantDb
.updateTable('organizationSettings')
.set({ refreshRequestedAt: new Date().toISOString() })
.execute()

return data({ intent: 'refresh' as const, ok: true })
try {
const run = await serverDurably.jobs.crawl.trigger(
{ organizationId: org.id, refresh: true },
{
concurrencyKey: `crawl:${org.id}`,
labels: { organizationId: org.id },
},
)
return data({ intent: 'refresh' as const, ok: true, runId: run.id })
} catch {
return data(
{ intent: 'refresh' as const, error: 'Failed to start refresh' },
{ status: 500 },
)
}
})
.with('recalculate', async () => {
const selectedSteps = formData.getAll('steps').map(String)
Expand All @@ -75,73 +66,169 @@ export const action = async ({ request, context }: Route.ActionArgs) => {
)
}

const run = await serverDurably.jobs.recalculate.trigger(
{ organizationId: org.id, steps },
{
concurrencyKey: `recalculate:${org.id}`,
labels: { organizationId: org.id },
},
)

return data({
intent: 'recalculate' as const,
ok: true,
runId: run.id,
})
try {
const run = await serverDurably.jobs.recalculate.trigger(
{ organizationId: org.id, steps },
{
concurrencyKey: `recalculate:${org.id}`,
labels: { organizationId: org.id },
},
)
return data({
intent: 'recalculate' as const,
ok: true,
runId: run.id,
})
} catch {
return data(
{
intent: 'recalculate' as const,
error: 'Failed to start recalculation',
},
{ status: 500 },
)
}
})
.otherwise(() => data({ error: 'Invalid intent' }, { status: 400 }))
}

// --- Refresh Section ---
// --- Shared Run Status Alerts ---

function RefreshSection({
refreshRequestedAt,
function RunStatusAlerts({
label,
progress,
output,
runError,
triggerError,
isRunning,
isCompleted,
isFailed,
}: {
refreshRequestedAt: string | null
label: string
progress: { message?: string; current?: number; total?: number } | null
output: { pullCount?: number } | null
runError: string | null
triggerError: string | null
isRunning: boolean
isCompleted: boolean
isFailed: boolean
}) {
const timezone = useTimezone()
if (isRunning && progress) {
return (
<Alert>
<AlertDescription>
<div className="space-y-2">
<p className="text-sm">{progress.message ?? 'Processing...'}</p>
{progress.current != null &&
progress.total != null &&
progress.total > 0 && (
<Progress
value={(progress.current / progress.total) * 100}
className="h-2"
/>
)}
</div>
</AlertDescription>
</Alert>
)
}

if (isRunning) {
return (
<Alert>
<AlertDescription>Starting {label}...</AlertDescription>
</Alert>
)
}

const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1)

if (isCompleted) {
return (
<Alert>
<AlertDescription>
{capitalizedLabel} completed.{' '}
{output?.pullCount != null && `${output.pullCount} PRs updated.`}
</AlertDescription>
</Alert>
)
}

if (isFailed) {
return (
<Alert variant="destructive">
<AlertDescription>
{capitalizedLabel} failed. {runError}
</AlertDescription>
</Alert>
)
}

if (triggerError) {
return (
<Alert variant="destructive">
<AlertDescription>{triggerError}</AlertDescription>
</Alert>
)
}

return null
}

// --- Refresh Section ---

function RefreshSection() {
const fetcher = useFetcher()
const isSubmitting = fetcher.state !== 'idle'
const isScheduled =
refreshRequestedAt != null ||
(fetcher.data?.intent === 'refresh' && fetcher.data?.ok === true)

const runId =
fetcher.data?.intent === 'refresh' && fetcher.data?.ok
? fetcher.data.runId
: null
const {
progress,
output,
error: runError,
isPending,
isLeased,
isCompleted,
isFailed,
} = durably.crawl.useRun(runId)

const isRunning = isPending || isLeased

return (
<Stack>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="flex items-center gap-2 text-sm font-medium">
Schedule Full Refresh
{isScheduled && <Badge variant="secondary">Scheduled</Badge>}
Full Refresh
{isRunning && <Badge variant="secondary">Running</Badge>}
</p>
<p className="text-muted-foreground text-xs">
Re-fetch all PR data from GitHub on the next hourly crawl.
Re-fetch all PR data from GitHub immediately.
</p>
</div>
<fetcher.Form method="post" className="shrink-0">
<input type="hidden" name="intent" value="refresh" />
<Button type="submit" loading={isSubmitting} disabled={isScheduled}>
{isScheduled ? 'Scheduled' : 'Schedule'}
<Button type="submit" loading={isSubmitting} disabled={isRunning}>
{isRunning ? 'Running' : 'Refresh'}
</Button>
</fetcher.Form>
</div>
{isScheduled && refreshRequestedAt && (
<Alert>
<AlertDescription>
Scheduled at{' '}
{dayjs
.utc(refreshRequestedAt)
.tz(timezone)
.format('YYYY-MM-DD HH:mm:ss')}
. It will run on the next crawl job.
</AlertDescription>
</Alert>
)}
{fetcher.data?.error && (
<Alert variant="destructive">
<AlertDescription>{fetcher.data.error}</AlertDescription>
</Alert>
)}

<RunStatusAlerts
label="full refresh"
progress={progress}
output={output}
runError={runError}
triggerError={
fetcher.data?.intent === 'refresh' ? fetcher.data?.error : null
}
isRunning={isRunning}
isCompleted={isCompleted}
isFailed={isFailed}
/>
</Stack>
)
}
Expand All @@ -155,7 +242,6 @@ function RecalculateSection() {
const [exportData, setExportData] = useState(false)
const noneSelected = !upsert && !classify && !exportData

// After form submission, track the durably run via SSE
const runId =
fetcher.data?.intent === 'recalculate' && fetcher.data?.ok
? fetcher.data.runId
Expand Down Expand Up @@ -237,53 +323,18 @@ function RecalculateSection() {
</Stack>
</fetcher.Form>

{/* Progress */}
{isRunning && progress && (
<Alert>
<AlertDescription>
<div className="space-y-2">
<p className="text-sm">{progress.message ?? 'Processing...'}</p>
{progress.current != null &&
progress.total != null &&
progress.total > 0 && (
<Progress
value={(progress.current / progress.total) * 100}
className="h-2"
/>
)}
</div>
</AlertDescription>
</Alert>
)}

{isRunning && !progress && (
<Alert>
<AlertDescription>Starting recalculation...</AlertDescription>
</Alert>
)}

{/* Success */}
{isCompleted && (
<Alert>
<AlertDescription>
Recalculation completed.{' '}
{output?.pullCount != null && `${output.pullCount} PRs updated.`}
</AlertDescription>
</Alert>
)}

{/* Error */}
{isFailed && (
<Alert variant="destructive">
<AlertDescription>Recalculation failed. {runError}</AlertDescription>
</Alert>
)}

{fetcher.data?.intent === 'recalculate' && fetcher.data?.error && (
<Alert variant="destructive">
<AlertDescription>{fetcher.data.error}</AlertDescription>
</Alert>
)}
<RunStatusAlerts
label="recalculation"
progress={progress}
output={output}
runError={runError}
triggerError={
fetcher.data?.intent === 'recalculate' ? fetcher.data?.error : null
}
isRunning={isRunning}
isCompleted={isCompleted}
isFailed={isFailed}
/>
</Stack>
)
}
Expand Down Expand Up @@ -333,7 +384,6 @@ function ExportDataSection({ orgSlug }: { orgSlug: string }) {
// --- Page ---

export default function DataManagementPage({
loaderData: { refreshRequestedAt },
params: { orgSlug },
}: Route.ComponentProps) {
return (
Expand All @@ -342,7 +392,7 @@ export default function DataManagementPage({
desc="Manage data refresh and recalculation for this organization."
>
<Stack gap="6">
<RefreshSection refreshRequestedAt={refreshRequestedAt} />
<RefreshSection />
<RecalculateSection />
<ExportDataSection orgSlug={orgSlug} />
</Stack>
Expand Down
Loading