diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 1488af1..63db51c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,8 +9,10 @@ */ import type * as actions from "../actions.js"; +import type * as admin from "../admin.js"; import type * as auth from "../auth.js"; import type * as builds from "../builds.js"; +import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as plugins from "../plugins.js"; @@ -24,8 +26,10 @@ import type { declare const fullApi: ApiFromModules<{ actions: typeof actions; + admin: typeof admin; auth: typeof auth; builds: typeof builds; + helpers: typeof helpers; http: typeof http; "lib/r2": typeof lib_r2; plugins: typeof plugins; diff --git a/convex/admin.ts b/convex/admin.ts new file mode 100644 index 0000000..00382c4 --- /dev/null +++ b/convex/admin.ts @@ -0,0 +1,77 @@ +import { getAuthUserId } from '@convex-dev/auth/server' +import { v } from 'convex/values' +import { api } from './_generated/api' +import { query } from './_generated/server' +import { computeFlagsFromConfig } from './builds' +import { adminMutation, adminQuery } from './helpers' + +export const isAdmin = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx) + if (!userId) return false + + const userSettings = await ctx.db + .query('userSettings') + .withIndex('by_user', (q) => q.eq('userId', userId)) + .first() + + return userSettings?.isAdmin === true + }, +}) + +export const listFailedBuilds = adminQuery({ + args: {}, + handler: async (ctx) => { + const failedBuilds = await ctx.db + .query('builds') + .filter((q) => q.eq(q.field('status'), 'failure')) + .order('desc') + .collect() + + return failedBuilds + }, +}) + +export const listAllBuilds = adminQuery({ + args: {}, + handler: async (ctx) => { + const allBuilds = await ctx.db.query('builds').order('desc').collect() + + return allBuilds + }, +}) + +export const retryBuild = adminMutation({ + args: { + buildId: v.id('builds'), + }, + handler: async (ctx, args) => { + const build = await ctx.db.get(args.buildId) + if (!build) { + throw new Error('Build not found') + } + + // Compute flags from config + const flags = computeFlagsFromConfig(build.config) + + // Dispatch new GitHub build with same config + // This will use the latest YAML from the branch + await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { + target: build.config.target, + version: build.config.version, + buildId: args.buildId, + flags, + buildHash: build.buildHash, + plugins: build.config.pluginsEnabled ?? [], + }) + + // Update build status to queued + await ctx.db.patch(args.buildId, { + status: 'queued', + updatedAt: Date.now(), + }) + + return { success: true } + }, +}) diff --git a/convex/builds.ts b/convex/builds.ts index af0f448..4373292 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -252,6 +252,7 @@ export const updateBuildStatus = internalMutation({ artifactPath?: string sourceUrl?: string githubRunId?: number + githubRunIdHistory?: number[] } = { status: args.status, } @@ -272,10 +273,22 @@ export const updateBuildStatus = internalMutation({ } // Set githubRunId if provided + // When a new run ID comes in, move the previous one to history + const existingHistory = [...new Set(build.githubRunIdHistory || [])] if (args.githubRunId !== undefined) { + const existingRunId = build.githubRunId + // Only update if the run ID is actually changing + if (existingRunId !== undefined && existingRunId !== args.githubRunId) { + // Prepend existing run ID to history array, avoiding duplicates + existingHistory.unshift(existingRunId) + } updateData.githubRunId = args.githubRunId } + updateData.githubRunIdHistory = [...new Set(existingHistory)].filter( + (id) => id !== args.githubRunId + ) + await ctx.db.patch(args.buildId, updateData) }, }) diff --git a/convex/helpers.ts b/convex/helpers.ts new file mode 100644 index 0000000..996528b --- /dev/null +++ b/convex/helpers.ts @@ -0,0 +1,118 @@ +import { getAuthUserId } from '@convex-dev/auth/server' +import { ConvexError, v } from 'convex/values' +import { + customAction, + customCtx, + customMutation, + customQuery, +} from 'convex-helpers/server/customFunctions' +import { api } from './_generated/api' +import { action, mutation, query } from './_generated/server' + +export const authQuery = customQuery( + query, + customCtx(async (ctx) => { + const identity = await ctx.auth.getUserIdentity() + if (identity === null) { + throw new ConvexError('Not authenticated!') + } + return {} + }) +) + +export const authMutation = customMutation( + mutation, + customCtx(async (ctx) => { + const identity = await ctx.auth.getUserIdentity() + if (identity === null) { + throw new ConvexError('Not authenticated!') + } + return {} + }) +) + +export const authAction = customAction( + action, + customCtx(async (ctx) => { + const identity = await ctx.auth.getUserIdentity() + if (identity === null) { + throw new ConvexError('Not authenticated!') + } + return {} + }) +) + +export const adminQuery = customQuery( + query, + customCtx(async (ctx) => { + const userId = await getAuthUserId(ctx) + if (!userId) { + throw new ConvexError('Not authenticated!') + } + + const userSettings = await ctx.db + .query('userSettings') + .withIndex('by_user', (q) => q.eq('userId', userId)) + .first() + + if (userSettings?.isAdmin !== true) { + throw new ConvexError('Unauthorized: Admin access required') + } + + return {} + }) +) + +export const adminMutation = customMutation( + mutation, + customCtx(async (ctx) => { + const userId = await getAuthUserId(ctx) + if (!userId) { + throw new ConvexError('Not authenticated!') + } + + const userSettings = await ctx.db + .query('userSettings') + .withIndex('by_user', (q) => q.eq('userId', userId)) + .first() + + if (userSettings?.isAdmin !== true) { + throw new ConvexError('Unauthorized: Admin access required') + } + + return {} + }) +) + +export const adminAction = customAction( + action, + customCtx(async (ctx) => { + const userId = await getAuthUserId(ctx) + if (!userId) { + throw new ConvexError('Not authenticated!') + } + + // Actions can't access ctx.db directly, so we need to use a query + const isAdmin = await ctx.runQuery(api.helpers.checkIsAdmin, { + userId, + }) + if (!isAdmin) { + throw new ConvexError('Unauthorized: Admin access required') + } + + return {} + }) +) + +// Internal query to check if user is admin (used by action middleware) +export const checkIsAdmin = query({ + args: { userId: v.id('users') }, + handler: async (ctx, args) => { + const userSettings = await ctx.db + .query('userSettings') + .withIndex('by_user', (q) => q.eq('userId', args.userId)) + .first() + + return userSettings?.isAdmin === true + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index bd1e155..0e0fbb8 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -32,6 +32,7 @@ export const buildFields = { artifactPath: v.optional(v.string()), sourceUrl: v.optional(v.string()), githubRunId: v.optional(v.number()), + githubRunIdHistory: v.optional(v.array(v.number())), } export const pluginFields = { @@ -40,11 +41,17 @@ export const pluginFields = { updatedAt: v.number(), } +export const userSettingsFields = { + userId: v.id('users'), + isAdmin: v.boolean(), +} + export const schema = defineSchema({ ...authTables, profiles: defineTable(profileFields), builds: defineTable(buildFields), plugins: defineTable(pluginFields).index('by_slug', ['slug']), + userSettings: defineTable(userSettingsFields).index('by_user', ['userId']), }) export type ProfilesDoc = Doc<'profiles'> diff --git a/src/App.tsx b/src/App.tsx index a080e79..157e6d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { } from 'react-router-dom' import { Toaster } from '@/components/ui/sonner' import Navbar from './components/Navbar' +import Admin from './pages/Admin' import BuildNew from './pages/BuildNew' import BuildProgress from './pages/BuildProgress' import Dashboard from './pages/Dashboard' @@ -51,6 +52,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 614ebb6..317106a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,23 +1,67 @@ +import { useAuthActions } from '@convex-dev/auth/react' +import { Authenticated, Unauthenticated, useQuery } from 'convex/react' import { Link } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { api } from '../../convex/_generated/api' export default function Navbar() { + const { signIn, signOut } = useAuthActions() + const isAdmin = useQuery(api.admin.isAdmin) + return ( diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..5ac881d --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,221 @@ +import { useMutation, useQuery } from 'convex/react' +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { api } from '../../convex/_generated/api' +import type { Id } from '../../convex/_generated/dataModel' + +type FilterType = 'all' | 'failed' + +export default function Admin() { + const navigate = useNavigate() + const [filter, setFilter] = useState('failed') + const isAdmin = useQuery(api.admin.isAdmin) + const failedBuilds = useQuery(api.admin.listFailedBuilds) + const allBuilds = useQuery(api.admin.listAllBuilds) + const retryBuild = useMutation(api.admin.retryBuild) + + const builds = filter === 'failed' ? failedBuilds : allBuilds + + // Show loading state + if (isAdmin === undefined) { + return ( +
+
Loading...
+
+ ) + } + + // Redirect if not admin + if (isAdmin === false) { + return ( +
+
+

Access Denied

+

+ You must be an admin to access this page. +

+ +
+
+ ) + } + + const handleRetry = async (buildId: Id<'builds'>) => { + try { + await retryBuild({ buildId }) + toast.success('Build retry initiated', { + description: 'The build has been queued with the latest YAML.', + }) + } catch (error) { + toast.error('Failed to retry build', { + description: String(error), + }) + } + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleString() + } + + const getStatusBadge = (status: string) => { + const statusConfig = { + success: { + bg: 'bg-green-500/20', + text: 'text-green-400', + label: 'Success', + }, + failure: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Failed' }, + queued: { + bg: 'bg-yellow-500/20', + text: 'text-yellow-400', + label: 'Queued', + }, + } + const config = statusConfig[status as keyof typeof statusConfig] || { + bg: 'bg-slate-500/20', + text: 'text-slate-400', + label: status, + } + return ( + + {config.label} + + ) + } + + return ( +
+
+

Admin - Builds

+

+ View and manage builds. Retry failed builds with the latest GitHub + Actions workflow YAML. +

+
+ + +
+
+ +
+ {builds === undefined ? ( +
+ Loading builds... +
+ ) : builds.length === 0 ? ( +
+ No {filter === 'failed' ? 'failed ' : ''}builds found. +
+ ) : ( +
+ {builds.map((build) => ( +
+
+
+
+

+ Build:{' '} + + {build.buildHash.substring(0, 8)} + +

+ {getStatusBadge(build.status)} +
+
+
+ Target:{' '} + {build.config.target} +
+
+ Version:{' '} + + {build.config.version} + +
+
+ + {build.completedAt ? 'Completed' : 'Started'}: + {' '} + {build.completedAt + ? formatDate(build.completedAt) + : build.startedAt + ? formatDate(build.startedAt) + : 'Unknown'} +
+
+ Run ID:{' '} + {build.githubRunId ? ( + + {build.githubRunId} + + ) : ( + 'N/A' + )} +
+
+ {build.githubRunIdHistory && + build.githubRunIdHistory.length > 0 && ( +
+ Previous runs:{' '} + {build.githubRunIdHistory.map((id, idx) => ( + + + {id} + + {idx < + (build.githubRunIdHistory?.length ?? 0) - 1 && + ', '} + + ))} +
+ )} +
+
+ +
+
+
+ ))} +
+ )} +
+
+ ) +}