Skip to content
Merged
267 changes: 238 additions & 29 deletions apps/admin/app/routes/_app+/$project._index/route.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useJob, useRuns } from '@coji/durably-react/client'
import { zx } from '@coji/zodix/v4'
import { ArrowLeftIcon, LoaderCircleIcon } from 'lucide-react'
import { ArrowLeftIcon, HistoryIcon, LoaderCircleIcon } from 'lucide-react'
import { useState } from 'react'
import { Form, href, Link, useNavigate, useNavigation } from 'react-router'
import { z } from 'zod'
import {
Expand All @@ -12,23 +14,43 @@ import {
CardTitle,
HStack,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '~/components/ui'
import dayjs from '~/libs/dayjs'
import type { Route } from './+types/route'
import {
exportFiles,
getProjectDetails,
rescanFiles,
startTranslationJob,
} from './functions.server'
import { exportFiles, getProjectDetails, rescanFiles } from './functions.server'

// Type definitions for durably job data
type TranslationJobOutput = {
translatedCount: number
errorCount: number
totalCount: number
errors: { path: string; error: string }[]
}

type JobProgress = {
current: number
total: number
message?: string
}

export const meta = ({ loaderData }: Route.MetaArgs) => [
{ title: `${loaderData?.project.id}` },
Expand Down Expand Up @@ -70,13 +92,6 @@ export const action = async ({ request, params }: Route.ActionArgs) => {
}
}

if (intent === 'start-translation-job') {
return {
intent: 'start-translation-job',
translation_result: await startTranslationJob(projectId),
}
}

if (intent === 'export-files') {
return {
intent: 'export-files',
Expand All @@ -100,13 +115,36 @@ export default function ProjectDetail({
const isRescanInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'rescan-project'
const isTranslationInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'start-translation-job'
const isExportInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'export-files'

// Job history
const [historyOpen, setHistoryOpen] = useState(false)
const { runs } = useRuns({
api: '/api/durably',
jobName: 'translate-project',
pageSize: 20,
})

// Find the latest running job to follow
const latestRunningJob = runs?.find(
(run) => run.status === 'running' || run.status === 'pending',
)

// Use durably for translation job
const translationJob = useJob({
api: '/api/durably',
jobName: 'translate-project',
initialRunId: latestRunningJob?.id,
})

const handleStartTranslation = () => {
translationJob.trigger({ projectId: project.id })
}

const isTranslationRunning = translationJob.isRunning

return (
<Card>
<CardHeader>
Expand Down Expand Up @@ -140,11 +178,11 @@ export default function ProjectDetail({
</Button>

<Button
name="intent"
value="start-translation-job"
disabled={isSubmitting}
type="button"
onClick={handleStartTranslation}
disabled={isSubmitting || isTranslationRunning}
>
{isTranslationInProgress && (
{isTranslationRunning && (
<LoaderCircleIcon size="16" className="mr-2 animate-spin" />
)}
Start Translation
Expand All @@ -156,6 +194,144 @@ export default function ProjectDetail({
Export files
</Button>

<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetTrigger asChild>
<Button type="button" variant="outline">
<HistoryIcon size="16" />
History
</Button>
</SheetTrigger>
<SheetContent className="w-125 sm:max-w-125">
<SheetHeader>
<SheetTitle>Translation History</SheetTitle>
<SheetDescription>
Past translation job runs
</SheetDescription>
</SheetHeader>
<div className="mt-4 space-y-2">
{runs?.map((run) => (
<div
key={run.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant={
run.status === 'completed'
? 'default'
: run.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{run.status === 'running' && (
<LoaderCircleIcon
size="12"
className="mr-1 animate-spin"
/>
)}
{run.status}
</Badge>
<span className="text-muted-foreground text-xs">
{run.id.slice(0, 8)}
</span>
</div>
<div className="text-muted-foreground text-xs">
{dayjs(run.createdAt)
.utc()
.tz()
.format('YYYY-MM-DD HH:mm:ss')}
</div>
{/* Progress for running jobs */}
{(run.status === 'running' || run.status === 'pending') &&
run.progress &&
(() => {
const progress = run.progress as JobProgress
return (
<div className="w-full space-y-1">
<div className="text-xs">
{progress.current ?? 0}/{progress.total ?? 0}
</div>
<Progress
value={
((progress.current ?? 0) /
(progress.total ?? 1)) *
100
}
className="h-1.5"
/>
</div>
)
})()}
{/* Output for completed jobs */}
{run.output &&
(() => {
const output = run.output as TranslationJobOutput
return (
<div className="text-xs">
{output.translatedCount ?? 0} translated,{' '}
{(output.errorCount ?? 0) > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-destructive cursor-help underline">
{output.errorCount ?? 0} errors
</span>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-100"
>
<ul className="space-y-1">
{(output.errors ?? []).map((e, i) => (
<li key={i} className="text-xs">
<span className="font-medium">
{e.path}
</span>
: {e.error}
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span>0 errors</span>
)}
</div>
)
})()}
{/* Error for failed jobs */}
{run.status === 'failed' && run.error && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-destructive max-w-80 cursor-help truncate text-xs">
{String(run.error)}
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-100 whitespace-pre-wrap"
>
{String(run.error)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
))}
{(!runs || runs.length === 0) && (
<div className="text-muted-foreground py-8 text-center text-sm">
No translation history yet
</div>
)}
</div>
</SheetContent>
</Sheet>

<div className="flex-1" />

<HStack>
Expand All @@ -174,6 +350,47 @@ export default function ProjectDetail({
</HStack>
</Form>

{/* Translation Progress */}
{translationJob.status && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span>
Translation: {translationJob.status}
{translationJob.progress && (
<span className="text-muted-foreground ml-2">
({translationJob.progress.current}/
{translationJob.progress.total})
</span>
)}
</span>
{translationJob.progress?.message && (
<span className="text-muted-foreground">
{translationJob.progress.message}
</span>
)}
</div>
{translationJob.progress?.total && (
<Progress
value={
(translationJob.progress.current /
translationJob.progress.total) *
100
}
/>
)}
{translationJob.output &&
(() => {
const output = translationJob.output as TranslationJobOutput
return (
<div className="text-muted-foreground text-sm">
Completed: {output.translatedCount} translated,{' '}
{output.errorCount} errors
</div>
)
})()}
</div>
)}

<div>
{actionData?.intent === 'rescan-project' &&
actionData.rescan_result && (
Expand Down Expand Up @@ -220,14 +437,6 @@ export default function ProjectDetail({
</div>
)}

{actionData?.intent === 'start-translation-job' &&
actionData.translation_result && (
<div>
<div>Translation job started</div>
<div>Job ID: {actionData.translation_result.id}</div>
</div>
)}

{actionData?.intent === 'export-files' &&
actionData.export_result && (
<div>
Expand Down
10 changes: 10 additions & 0 deletions apps/admin/app/routes/api.durably.$.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { durablyHandler } from '~/services/durably.server'
import type { Route } from './+types/api.durably.$'

export async function loader({ request }: Route.LoaderArgs) {
return await durablyHandler.handle(request, '/api/durably')
}

export async function action({ request }: Route.ActionArgs) {
return await durablyHandler.handle(request, '/api/durably')
}
Comment on lines +1 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the durably.server file to understand the handler
find apps/admin -name "*durably*" -type f

Repository: coji/remix-docs-ja

Length of output: 142


🏁 Script executed:

# Check the admin app structure and look for layout files that might enforce auth
git ls-files apps/admin/app/routes | head -20

Repository: coji/remix-docs-ja

Length of output: 1261


🏁 Script executed:

# Search for how durablyHandler is defined and used
rg "durablyHandler" apps/admin --type ts --type tsx -B 2 -A 5

Repository: coji/remix-docs-ja

Length of output: 89


🏁 Script executed:

# Search for durablyHandler without specifying file types
rg "durablyHandler" apps/admin -B 2 -A 5

Repository: coji/remix-docs-ja

Length of output: 1039


🏁 Script executed:

# Read the durably.server.ts file to understand the handler implementation
cat -n apps/admin/app/services/durably.server.ts

Repository: coji/remix-docs-ja

Length of output: 4116


🏁 Script executed:

# Look for layout files in admin app that might enforce authentication
cat -n apps/admin/app/routes/_app+/_layout.tsx

Repository: coji/remix-docs-ja

Length of output: 744


🏁 Script executed:

# Check if there's a root layout in admin app
git ls-files apps/admin/app/routes | grep -E "layout|root"

Repository: coji/remix-docs-ja

Length of output: 101


🏁 Script executed:

# Look for root layout or auth middleware in admin app
git ls-files apps/admin/app | grep -E "root|layout|middleware|auth"

Repository: coji/remix-docs-ja

Length of output: 125


🏁 Script executed:

# Check the admin app root level for any layout or entry files
cat -n apps/admin/app/entry.server.tsx

Repository: coji/remix-docs-ja

Length of output: 125


🏁 Script executed:

# Look for route guards or middleware patterns
rg "beforeEach|middleware|guard|protect|auth" apps/admin/app/routes -A 3

Repository: coji/remix-docs-ja

Length of output: 44


🏁 Script executed:

# Check if there's a root layout that wraps the _app layout
git ls-files apps/admin/app/routes | grep -v "_app" | grep -E "route|layout" | head -10

Repository: coji/remix-docs-ja

Length of output: 100


🏁 Script executed:

# Check the root.tsx for auth protection or middleware
cat -n apps/admin/app/root.tsx

Repository: coji/remix-docs-ja

Length of output: 2148


🏁 Script executed:

# Look for any auth-related files in admin app
find apps/admin -name "*auth*" -o -name "*guard*" -o -name "*protect*"

Repository: coji/remix-docs-ja

Length of output: 44


🏁 Script executed:

# Check how the _app layout is nested relative to api routes
git ls-files apps/admin/app/routes | sort

Repository: coji/remix-docs-ja

Length of output: 1261


🏁 Script executed:

# Look for createDurablyHandler implementation to understand basePath param
rg "createDurablyHandler" --type ts -B 5 -A 10

Repository: coji/remix-docs-ja

Length of output: 1346


🏁 Script executed:

# Check if there are any query/mutation files in the _app+ layout to understand the auth pattern
find apps/admin/app/routes/_app+ -name "*.server.ts" | head -5 | xargs cat

Repository: coji/remix-docs-ja

Length of output: 1987


🏁 Script executed:

# Look for environment variables or secrets related to durably auth
rg "DURABLY|durably" apps/admin/app --type ts -i

Repository: coji/remix-docs-ja

Length of output: 1416


🏁 Script executed:

# Check if api.durably is explicitly referenced elsewhere or if there's any URL construction
rg "api/durably|/api/durably" apps/admin -i

Repository: coji/remix-docs-ja

Length of output: 421


🏁 Script executed:

# Look at the package.json to understand @coji/durably version and see if there's documentation
cat -n apps/admin/package.json | grep -A 5 -B 5 durably

Repository: coji/remix-docs-ja

Length of output: 563


🏁 Script executed:

# Check the _app+ layout more carefully and look for any middleware or data loading
cat -n apps/admin/app/routes/_app+/_layout.tsx

Repository: coji/remix-docs-ja

Length of output: 744


🏁 Script executed:

# Search for any server middleware or authentication enforcement
find apps/admin -name "*.server.*" | xargs rg "auth|middleware|guard" -l

Repository: coji/remix-docs-ja

Length of output: 46


🏁 Script executed:

# Look at how the client uses durably to understand the api endpoint better
cat -n apps/admin/app/routes/_app+/$project._index/route.tsx | grep -A 10 -B 10 "api/durably"

Repository: coji/remix-docs-ja

Length of output: 139


🏁 Script executed:

# Check if there's any environment setup or server configuration
git ls-files apps/admin | grep -E "server|config|middleware|auth"

Repository: coji/remix-docs-ja

Length of output: 894


🏁 Script executed:

# Find the correct path for the project index route
find apps/admin/app/routes -name "*project*" -name "*index*"

Repository: coji/remix-docs-ja

Length of output: 155


🏁 Script executed:

# Look at the project index route
cat -n apps/admin/app/routes/_app+/$project._index/route.tsx | head -80

Repository: coji/remix-docs-ja

Length of output: 139


🏁 Script executed:

# Check if there's a loader or action in the project index route that might check auth
rg "loader|action" apps/admin/app/routes/_app+/$project._index/route.tsx -A 10

Repository: coji/remix-docs-ja

Length of output: 147


🏁 Script executed:

# Look at the root loader to see if there's any global auth setup
rg "loader" apps/admin/app/root.tsx -A 15

Repository: coji/remix-docs-ja

Length of output: 1008


🏁 Script executed:

# Read the project index route correctly
cat -n "apps/admin/app/routes/_app+/\$project._index/route.tsx" | head -100

Repository: coji/remix-docs-ja

Length of output: 3303


🏁 Script executed:

# Search for where the durably api is configured on the client
rg "api.*durably" apps/admin/app/routes/_app+ -B 5 -A 5

Repository: coji/remix-docs-ja

Length of output: 1828


🏁 Script executed:

# Look for the actual project index route using find
find apps/admin/app/routes/_app+ -type d -name "*project*"

Repository: coji/remix-docs-ja

Length of output: 301


🏁 Script executed:

# Check the directory contents directly
ls -la "apps/admin/app/routes/_app+/" 2>/dev/null | head -20

Repository: coji/remix-docs-ja

Length of output: 677


🏁 Script executed:

# Check the route structure more carefully - is api.durably at the same level as _app+ or nested?
git ls-files apps/admin/app/routes | grep -E "^apps/admin/app/routes/[^/]+$"

Repository: coji/remix-docs-ja

Length of output: 100


🏁 Script executed:

# Confirm the api.durably.$.ts file location
ls -la apps/admin/app/routes/ | grep durably

Repository: coji/remix-docs-ja

Length of output: 126


🏁 Script executed:

# Look at the actual file structure to understand nesting
find apps/admin/app/routes -maxdepth 1 -type f -o -type d | sort

Repository: coji/remix-docs-ja

Length of output: 150


🏁 Script executed:

# Check if there's any global middleware or auth setup that would protect ALL routes
rg "middleware|beforeEach|guard" apps/admin/app -B 2 -A 5

Repository: coji/remix-docs-ja

Length of output: 44


🏁 Script executed:

# Check if there's a server configuration or middleware setup in react-router config
cat -n apps/admin/react-router.config.ts

Repository: coji/remix-docs-ja

Length of output: 201


🏁 Script executed:

# Look for any server middleware or guards in vite config
cat -n apps/admin/vite.config.ts

Repository: coji/remix-docs-ja

Length of output: 851


🏁 Script executed:

# Search for any auth patterns or middleware in the entire admin app
rg "middleware|createMiddleware|auth" apps/admin --type ts | head -20

Repository: coji/remix-docs-ja

Length of output: 590


🏁 Script executed:

# Check if there's environment-based protection or any README documenting auth
find apps/admin -name "README*" -o -name "*.md" | xargs cat 2>/dev/null | head -50

Repository: coji/remix-docs-ja

Length of output: 1763


🏁 Script executed:

# Look at the basePath parameter in @coji/durably context - check if it's used for routing validation
rg "basePath" apps/admin -B 3 -A 3

Repository: coji/remix-docs-ja

Length of output: 44


Add authentication checks to /api/durably/* endpoint to prevent unauthorized job operations.

The route is accessible without authentication. Any caller can trigger or query translation jobs unless protection is enforced at the handler or request level. This is a real risk if the admin app is exposed beyond internal use.

The basePath parameter is correct for splat routing.

Optional refactoring (eliminate code duplication)
 import { durablyHandler } from '~/services/durably.server'
 import type { Route } from './+types/api.durably.$'

+const handle = (request: Request) => durablyHandler.handle(request, '/api/durably')
+
 export async function loader({ request }: Route.LoaderArgs) {
-  return await durablyHandler.handle(request, '/api/durably')
+  return handle(request)
 }

 export async function action({ request }: Route.ActionArgs) {
-  return await durablyHandler.handle(request, '/api/durably')
+  return handle(request)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { durablyHandler } from '~/services/durably.server'
import type { Route } from './+types/api.durably.$'
export async function loader({ request }: Route.LoaderArgs) {
return await durablyHandler.handle(request, '/api/durably')
}
export async function action({ request }: Route.ActionArgs) {
return await durablyHandler.handle(request, '/api/durably')
}
import { durablyHandler } from '~/services/durably.server'
import type { Route } from './+types/api.durably.$'
const handle = (request: Request) => durablyHandler.handle(request, '/api/durably')
export async function loader({ request }: Route.LoaderArgs) {
return handle(request)
}
export async function action({ request }: Route.ActionArgs) {
return handle(request)
}

2 changes: 1 addition & 1 deletion apps/admin/app/services/db.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const parseDbPath = (url: string): string => {
return url.replace(/^sqlite:\/\//, '').replace(/^file:/, '')
}

const dialect = new SqliteDialect({
export const dialect = new SqliteDialect({
database: new Database(
parseDbPath(process.env.DATABASE_URL ?? DEFAULT_DB_PATH),
),
Expand Down
Loading