diff --git a/.github/workflows/release-docker-assets.yml b/.github/workflows/release-docker-assets.yml index fc03a72c8..d8f236c52 100644 --- a/.github/workflows/release-docker-assets.yml +++ b/.github/workflows/release-docker-assets.yml @@ -6,6 +6,7 @@ on: - 'main' paths: - 'apps/ui-server-auth/**' + - 'apps/ui-admin/**' workflow_dispatch: jobs: @@ -20,8 +21,14 @@ jobs: include: - assets_name: ui-server-auth build_directory: ./apps/server/public/ui-server-auth + dockerfile: ./apps/ui-server-auth/Dockerfile build_command: | pnpm -F @proj-airi/ui-server-auth run build + - assets_name: ui-admin + build_directory: ./apps/server/public/ui-admin + dockerfile: ./apps/ui-admin/Dockerfile + build_command: | + pnpm -F @proj-airi/ui-admin run build steps: # Why? # @@ -68,8 +75,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: - context: ./apps/server/public/ui-server-auth - file: ./apps/ui-server-auth/Dockerfile + context: ${{ matrix.build_directory }} + file: ${{ matrix.dockerfile }} build-args: | VITE_ENABLE_POSTHOG=true platforms: linux/amd64,linux/arm64,linux/arm64/v8 diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index fe0aef534..5096c4112 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -9,6 +9,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ COPY apps/server apps/server COPY --from=ghcr.io/moeru-ai/airi/ui-server-auth:latest /app/airi/projects/ui/ui-server-auth apps/server/public/ui-server-auth +COPY --from=ghcr.io/moeru-ai/airi/ui-admin:latest /app/airi/projects/ui/ui-admin apps/server/public/ui-admin COPY packages/server-schema packages/server-schema COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/apps/server/production/railway/Dockerfile b/apps/server/production/railway/Dockerfile index 770c7d548..72cf2b305 100644 --- a/apps/server/production/railway/Dockerfile +++ b/apps/server/production/railway/Dockerfile @@ -7,6 +7,7 @@ RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ COPY --from=ghcr.io/moeru-ai/airi/ui-server-auth:latest /app/airi/projects/ui/ui-server-auth apps/server/public/ui-server-auth +COPY --from=ghcr.io/moeru-ai/airi/ui-admin:latest /app/airi/projects/ui/ui-admin apps/server/public/ui-admin COPY apps/server apps/server COPY packages/server-schema packages/server-schema COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/apps/server/railway.toml b/apps/server/railway.toml index 28975201d..1e0f24420 100644 --- a/apps/server/railway.toml +++ b/apps/server/railway.toml @@ -3,6 +3,7 @@ builder = "DOCKERFILE" dockerfilePath = "/apps/server/production/railway/Dockerfile" watchPatterns = [ "apps/server/**", + "apps/ui-admin/**", "packages/**", "pnpm-lock.yaml" ] diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index be827a3d4..35bf2394e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -51,6 +51,8 @@ import { registerActiveSessionsGauge } from './otel/gauges/active-sessions' import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users' import { registerRollingActiveUsersGauge } from './otel/gauges/rolling-active-users' import { registerTotalUsersGauge } from './otel/gauges/total-users' +import { createAdminRoutes } from './routes/admin' +import { createAdminUiRoutes } from './routes/admin-ui' import { createAdminRouterConfigRoutes } from './routes/admin/config/router' import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants' import { createAdminUsersRoutes } from './routes/admin/users' @@ -325,6 +327,12 @@ export async function buildApp(deps: AppDeps) { rateLimitMetrics: deps.otel?.rateLimit, })) + /** + * Admin dashboard SPA. Auth is enforced by `/api/admin/*`; the bundle + * itself is public so unauthenticated users can be redirected cleanly. + */ + .route('/', createAdminUiRoutes(deps.env)) + /** * Character routes are handled by the character service. */ @@ -381,6 +389,16 @@ export async function buildApp(deps: AppDeps) { */ .route('/api/admin/config/router', createAdminRouterConfigRoutes(deps.adminRouterConfigService)) + /** + * Admin dashboard support APIs: user search, balance adjustments, metrics, + * and editable LLM router config. + */ + .route('/api/admin', createAdminRoutes({ + db: deps.db, + billingService: deps.billingService, + configKV: deps.configKV, + })) + /** * Catch-all 404 in JSON. Replaces hono's default `text/html` "404 Not * Found" so unmatched routes (typos, stale email links, scanners) get a diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index 210a704cb..7d7aa30d7 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -27,6 +27,8 @@ export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandl // starts with `/api` and won't be matched by the `/auth/` startsWith. if ( c.req.path.startsWith('/auth/') + || c.req.path.startsWith('/admin/') + || c.req.path === '/admin' || c.req.path.startsWith('/api/auth/') || c.req.path === '/.well-known/oauth-authorization-server/api/auth' ) { diff --git a/apps/server/src/routes/admin-ui.ts b/apps/server/src/routes/admin-ui.ts new file mode 100644 index 000000000..e9be72870 --- /dev/null +++ b/apps/server/src/routes/admin-ui.ts @@ -0,0 +1,32 @@ +import type { Env } from '../libs/env' +import type { HonoEnv } from '../types/hono' + +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' + +import { getServerAdminUiDistDir, renderServerAdminUiHtml, SERVER_ADMIN_UI_BASE_PATH } from '../utils/server-admin-ui' + +const RE_SERVER_ADMIN_UI_BASE_PATH = /^\/admin/ + +export function createAdminUiRoutes(env: Env) { + return new Hono() + .get(SERVER_ADMIN_UI_BASE_PATH, c => c.redirect(`${SERVER_ADMIN_UI_BASE_PATH}/`)) + .get(`${SERVER_ADMIN_UI_BASE_PATH}/*`, async (c, next) => { + if (!shouldRenderAdminUiHtml(new URL(c.req.url).pathname)) + return next() + + return c.html(renderServerAdminUiHtml({ + apiServerUrl: env.API_SERVER_URL, + currentUrl: c.req.url, + })) + }) + .use(`${SERVER_ADMIN_UI_BASE_PATH}/*`, serveStatic({ + root: getServerAdminUiDistDir(), + rewriteRequestPath: (path: string) => path.replace(RE_SERVER_ADMIN_UI_BASE_PATH, ''), + })) +} + +function shouldRenderAdminUiHtml(pathname: string): boolean { + const segment = pathname.split('/').pop() ?? '' + return segment === '' || segment === 'index.html' || !segment.includes('.') +} diff --git a/apps/server/src/routes/admin/index.ts b/apps/server/src/routes/admin/index.ts new file mode 100644 index 000000000..47a4097be --- /dev/null +++ b/apps/server/src/routes/admin/index.ts @@ -0,0 +1,366 @@ +import type { Context } from 'hono' + +import type { Database } from '../../libs/db' +import type { ConfigKVService } from '../../services/adapters/config-kv' +import type { BillingService } from '../../services/domain/billing/billing-service' +import type { HonoEnv } from '../../types/hono' + +import { and, asc, count, desc, eq, gt, ilike, isNull, or, sql } from 'drizzle-orm' +import { Hono } from 'hono' +import { integer, maxLength, maxValue, minValue, nonEmpty, number, object, optional, pipe, safeParse, string } from 'valibot' + +import { adminGuard } from '../../middlewares/admin-guard' +import { authGuard } from '../../middlewares/auth' +import { session as sessionTable, user as userTable } from '../../schemas/accounts' +import { userFlux } from '../../schemas/flux' +import { fluxTransaction } from '../../schemas/flux-transaction' +import { llmRequestLog } from '../../schemas/llm-request-log' +import { createBadRequestError, createNotFoundError } from '../../utils/error' +import { createQueryIntegerSchema } from '../../utils/http-query' + +const MAX_FLUX_ADJUSTMENT = 1_000_000_000 + +const ListUsersQuerySchema = object({ + limit: createQueryIntegerSchema({ + defaultValue: 20, + minimum: 1, + maximum: 100, + }), + offset: createQueryIntegerSchema({ + defaultValue: 0, + minimum: 0, + }), + query: optional(pipe(string(), maxLength(200)), ''), + status: optional(pipe(string(), maxLength(20)), 'all'), + sortKey: optional(pipe(string(), maxLength(40)), 'createdAt'), + sortDirection: optional(pipe(string(), maxLength(10)), 'desc'), +}) + +const GrantUserFluxBodySchema = object({ + amount: pipe(number(), integer('amount must be an integer'), minValue(1, 'amount must be at least 1')), + description: pipe(string(), nonEmpty('description is required'), maxLength(500)), + idempotencyKey: optional(pipe(string(), maxLength(100))), +}) + +const SetUserFluxBodySchema = object({ + balance: pipe( + number(), + integer('balance must be an integer'), + minValue(0, 'balance must be at least 0'), + maxValue(MAX_FLUX_ADJUSTMENT, `balance must be at most ${MAX_FLUX_ADJUSTMENT}`), + ), + description: optional(pipe(string(), maxLength(500)), 'Admin balance adjustment'), +}) + +export interface AdminRoutesDeps { + db: Database + billingService: BillingService + configKV: ConfigKVService +} + +function serializeUser(row: { + id: string + name: string + email: string + emailVerified: boolean + image: string | null + createdAt: Date + updatedAt: Date + flux: number | null + stripeCustomerId: string | null +}) { + return { + id: row.id, + name: row.name, + email: row.email, + emailVerified: row.emailVerified, + image: row.image, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + flux: row.flux ?? 0, + stripeCustomerId: row.stripeCustomerId, + } +} + +function userSortExpression(sortKey: string) { + switch (sortKey) { + case 'name': + return userTable.name + case 'email': + return userTable.email + case 'status': + return userTable.emailVerified + case 'flux': + return sql`coalesce(${userFlux.flux}, 0)` + case 'createdAt': + return userTable.createdAt + default: + throw createBadRequestError('Invalid sort key', 'INVALID_SORT_KEY', { sortKey }) + } +} + +function userSortDirection(sortDirection: string) { + switch (sortDirection) { + case 'asc': + return asc + case 'desc': + return desc + default: + throw createBadRequestError('Invalid sort direction', 'INVALID_SORT_DIRECTION', { sortDirection }) + } +} + +function userStatusWhere(status: string) { + switch (status) { + case 'all': + return undefined + case 'verified': + return eq(userTable.emailVerified, true) + case 'unverified': + return eq(userTable.emailVerified, false) + default: + throw createBadRequestError('Invalid status filter', 'INVALID_STATUS_FILTER', { status }) + } +} + +async function readJson(c: Context): Promise { + const raw = await c.req.json().catch(() => null) + if (raw == null) + throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') + return raw +} + +async function ensureUserExists(db: Database, userId: string) { + const [target] = await db + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + + if (!target) + throw createNotFoundError('User not found', { userId }) +} + +export function createAdminRoutes(deps: AdminRoutesDeps) { + return new Hono() + .use('*', authGuard) + .use('*', adminGuard) + + .get('/me', async (c) => { + const user = c.get('user')! + return c.json({ + role: 'admin', + user: { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + }, + }) + }) + + .get('/metrics', async (c) => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + + const [ + totalUsers, + verifiedUsers, + activeSessions, + currentFlux, + issuedFlux, + llmRequests24h, + llmFlux24h, + adminUsers, + ] = await Promise.all([ + deps.db.select({ count: count() }).from(userTable), + deps.db.select({ count: count() }).from(userTable).where(eq(userTable.emailVerified, true)), + deps.db.select({ count: count() }).from(sessionTable).where(gt(sessionTable.expiresAt, new Date())), + deps.db.select({ total: sql`coalesce(sum(${userFlux.flux}), 0)::int` }).from(userFlux).where(isNull(userFlux.deletedAt)), + deps.db.select({ total: sql`coalesce(sum(${fluxTransaction.amount}) filter (where ${fluxTransaction.type} in ('credit', 'initial', 'promo')), 0)::int` }).from(fluxTransaction), + deps.db.select({ count: count() }).from(llmRequestLog).where(gt(llmRequestLog.createdAt, yesterday)), + deps.db.select({ total: sql`coalesce(sum(${llmRequestLog.fluxConsumed}), 0)::int` }).from(llmRequestLog).where(gt(llmRequestLog.createdAt, yesterday)), + deps.db + .select({ count: count() }) + .from(userTable) + .where(sql`'admin' = any(regexp_split_to_array(coalesce(${userTable.role}, ''), '\\s*,\\s*'))`), + ]) + + return c.json({ + totalUsers: Number(totalUsers[0]?.count ?? 0), + verifiedUsers: Number(verifiedUsers[0]?.count ?? 0), + activeSessions: Number(activeSessions[0]?.count ?? 0), + currentFlux: Number(currentFlux[0]?.total ?? 0), + issuedFlux: Number(issuedFlux[0]?.total ?? 0), + llmRequests24h: Number(llmRequests24h[0]?.count ?? 0), + llmFlux24h: Number(llmFlux24h[0]?.total ?? 0), + adminSeats: Number(adminUsers[0]?.count ?? 0), + grafanaEmbedUrl: null, + }) + }) + + .get('/users', async (c) => { + const query = safeParse(ListUsersQuerySchema, { + limit: c.req.query('limit'), + offset: c.req.query('offset'), + query: c.req.query('query'), + sortDirection: c.req.query('sortDirection'), + sortKey: c.req.query('sortKey'), + status: c.req.query('status'), + }) + + if (!query.success) { + throw createBadRequestError('Invalid query', 'INVALID_QUERY', query.issues) + } + + const search = query.output.query.trim() + const searchWhere = search + ? or( + eq(userTable.id, search), + ilike(userTable.email, `%${search}%`), + ilike(userTable.name, `%${search}%`), + ) + : undefined + const statusWhere = userStatusWhere(query.output.status) + const where = searchWhere && statusWhere + ? and(searchWhere, statusWhere) + : searchWhere ?? statusWhere + const sort = userSortDirection(query.output.sortDirection) + const sortExpression = userSortExpression(query.output.sortKey) + + const [rows, totalRows] = await Promise.all([ + deps.db + .select({ + id: userTable.id, + name: userTable.name, + email: userTable.email, + emailVerified: userTable.emailVerified, + image: userTable.image, + createdAt: userTable.createdAt, + updatedAt: userTable.updatedAt, + flux: userFlux.flux, + stripeCustomerId: userFlux.stripeCustomerId, + }) + .from(userTable) + .leftJoin(userFlux, and( + eq(userTable.id, userFlux.userId), + isNull(userFlux.deletedAt), + )) + .where(where) + .orderBy(sort(sortExpression), desc(userTable.createdAt)) + .limit(query.output.limit + 1) + .offset(query.output.offset), + deps.db + .select({ count: count() }) + .from(userTable) + .where(where), + ]) + + const hasMore = rows.length > query.output.limit + if (hasMore) + rows.pop() + + return c.json({ + users: rows.map(serializeUser), + hasMore, + nextOffset: hasMore ? query.output.offset + query.output.limit : null, + total: Number(totalRows[0]?.count ?? 0), + }) + }) + + .get('/users/:id', async (c) => { + const id = c.req.param('id') + + const [row] = await deps.db + .select({ + id: userTable.id, + name: userTable.name, + email: userTable.email, + emailVerified: userTable.emailVerified, + image: userTable.image, + createdAt: userTable.createdAt, + updatedAt: userTable.updatedAt, + flux: userFlux.flux, + stripeCustomerId: userFlux.stripeCustomerId, + }) + .from(userTable) + .leftJoin(userFlux, and( + eq(userTable.id, userFlux.userId), + isNull(userFlux.deletedAt), + )) + .where(eq(userTable.id, id)) + .limit(1) + + if (!row) + throw createNotFoundError('User not found', { id }) + + const transactions = await deps.db.query.fluxTransaction.findMany({ + where: eq(fluxTransaction.userId, id), + orderBy: [desc(fluxTransaction.createdAt)], + limit: 20, + }) + + return c.json({ + user: serializeUser(row), + recentFluxTransactions: transactions.map(tx => ({ + id: tx.id, + type: tx.type, + amount: tx.amount, + balanceBefore: tx.balanceBefore, + balanceAfter: tx.balanceAfter, + description: tx.description, + metadata: tx.metadata, + createdAt: tx.createdAt.toISOString(), + })), + }) + }) + + .post('/users/:id/flux/grant', async (c) => { + const actor = c.get('user')! + const userId = c.req.param('id') + await ensureUserExists(deps.db, userId) + + const parsed = safeParse(GrantUserFluxBodySchema, await readJson(c)) + if (!parsed.success) { + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parsed.issues) + } + + const result = await deps.billingService.creditFlux({ + userId, + amount: parsed.output.amount, + requestId: parsed.output.idempotencyKey, + description: parsed.output.description, + source: 'admin.user_grant', + type: 'promo', + auditMetadata: { + source: 'admin.user_grant', + issuedByUserId: actor.id, + }, + }) + + return c.json(result) + }) + + .patch('/users/:id/flux', async (c) => { + const actor = c.get('user')! + const userId = c.req.param('id') + await ensureUserExists(deps.db, userId) + + const parsed = safeParse(SetUserFluxBodySchema, await readJson(c)) + if (!parsed.success) { + throw createBadRequestError('Invalid request body', 'INVALID_BODY', parsed.issues) + } + + const result = await deps.billingService.setFlux({ + userId, + balance: parsed.output.balance, + description: parsed.output.description, + issuedByUserId: actor.id, + }) + + return c.json({ + ...result, + changed: result.balanceBefore !== result.balanceAfter, + }) + }) +} diff --git a/apps/server/src/utils/server-admin-ui.ts b/apps/server/src/utils/server-admin-ui.ts new file mode 100644 index 000000000..3b4f94c1a --- /dev/null +++ b/apps/server/src/utils/server-admin-ui.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +export const SERVER_ADMIN_UI_BASE_PATH = '/admin' + +const SERVER_ADMIN_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-admin', import.meta.url)) +const SERVER_ADMIN_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-admin/index.html', import.meta.url)) +const RE_HTML_LT = //g +const RE_HTML_AMP = /&/g +const RE_UNICODE_LINE_SEPARATOR = /\u2028/g +const RE_UNICODE_PARAGRAPH_SEPARATOR = /\u2029/g + +let cachedIndexHtml: string | null = null + +export interface ServerAdminUiContext { + apiServerUrl: string + currentUrl: string +} + +export function getServerAdminUiDistDir(): string { + return SERVER_ADMIN_UI_DIST_DIR +} + +export function renderServerAdminUiHtml(context: ServerAdminUiContext): string { + const indexHtml = getServerAdminUiIndexHtml() + + if (!indexHtml.includes('__AIRI_SERVER_ADMIN_CONTEXT__')) + throw new Error('ui-admin index.html is missing __AIRI_SERVER_ADMIN_CONTEXT__ placeholder') + + return indexHtml.replace('__AIRI_SERVER_ADMIN_CONTEXT__', serializeInlineJson(context)) +} + +function getServerAdminUiIndexHtml(): string { + if (cachedIndexHtml !== null) + return cachedIndexHtml + + cachedIndexHtml = readFileSync(SERVER_ADMIN_UI_INDEX_HTML_PATH, 'utf8') + return cachedIndexHtml +} + +function serializeInlineJson(value: unknown): string { + return JSON.stringify(value) + .replace(RE_HTML_LT, '\\u003c') + .replace(RE_HTML_GT, '\\u003e') + .replace(RE_HTML_AMP, '\\u0026') + .replace(RE_UNICODE_LINE_SEPARATOR, '\\u2028') + .replace(RE_UNICODE_PARAGRAPH_SEPARATOR, '\\u2029') +} diff --git a/apps/ui-admin/Dockerfile b/apps/ui-admin/Dockerfile new file mode 100644 index 000000000..88154b447 --- /dev/null +++ b/apps/ui-admin/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch + +WORKDIR /app/airi/projects/ui/ui-admin +COPY . /app/airi/projects/ui/ui-admin diff --git a/apps/ui-admin/index.html b/apps/ui-admin/index.html new file mode 100644 index 000000000..c45c50df5 --- /dev/null +++ b/apps/ui-admin/index.html @@ -0,0 +1,15 @@ + + + + + AIRI Admin + + + + +
+ + + + + diff --git a/apps/ui-admin/package.json b/apps/ui-admin/package.json new file mode 100644 index 000000000..3c7613340 --- /dev/null +++ b/apps/ui-admin/package.json @@ -0,0 +1,34 @@ +{ + "name": "@proj-airi/ui-admin", + "type": "module", + "private": true, + "description": "Admin dashboard for Project AIRI", + "scripts": { + "build": "vite build", + "dev": "vite --host", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@proj-airi/font-chillroundm": "workspace:^", + "@proj-airi/stage-shared": "workspace:^", + "@proj-airi/ui": "workspace:^", + "@vueuse/core": "^14.2.1", + "nprogress": "^0.2.0", + "pinia": "^3.0.4", + "vue": "catalog:", + "vue-router": "^5.0.4", + "vue-sonner": "^2.0.9" + }, + "devDependencies": { + "@iconify-json/lucide": "^1.2.102", + "@types/nprogress": "^0.2.3", + "@unocss/reset": "^66.6.8", + "@vitejs/plugin-vue": "^6.0.6", + "@vue-macros/volar": "^3.1.2", + "unocss": "^66.6.8", + "vite": "catalog:", + "vue-macros": "^3.1.2", + "vue-tsc": "^3.2.6" + } +} diff --git a/apps/ui-admin/src/App.vue b/apps/ui-admin/src/App.vue new file mode 100644 index 000000000..a5444314e --- /dev/null +++ b/apps/ui-admin/src/App.vue @@ -0,0 +1,144 @@ + + + diff --git a/apps/ui-admin/src/components/admin-list/AdminListPanel.vue b/apps/ui-admin/src/components/admin-list/AdminListPanel.vue new file mode 100644 index 000000000..d57fa19be --- /dev/null +++ b/apps/ui-admin/src/components/admin-list/AdminListPanel.vue @@ -0,0 +1,187 @@ + + + diff --git a/apps/ui-admin/src/main.ts b/apps/ui-admin/src/main.ts new file mode 100644 index 000000000..79df7e579 --- /dev/null +++ b/apps/ui-admin/src/main.ts @@ -0,0 +1,43 @@ +import NProgress from 'nprogress' + +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import { Toaster } from 'vue-sonner' + +import App from './App.vue' +import FluxPage from './pages/FluxPage.vue' +import LlmRouterPage from './pages/LlmRouterPage.vue' +import OverviewPage from './pages/OverviewPage.vue' +import UsersPage from './pages/UsersPage.vue' + +import '@proj-airi/font-chillroundm/index.css' +import '@unocss/reset/tailwind.css' +import 'vue-sonner/style.css' +import './styles/main.css' +import 'uno.css' + +const router = createRouter({ + history: createWebHistory('/admin/'), + routes: [ + { path: '/', component: OverviewPage }, + { path: '/users', component: UsersPage }, + { path: '/flux', component: FluxPage }, + { path: '/llm-router', component: LlmRouterPage }, + ], +}) + +router.beforeEach((to, from) => { + if (to.path !== from.path) + NProgress.start() +}) + +router.afterEach(() => { + NProgress.done() +}) + +createApp(App) + .use(createPinia()) + .use(router) + .component('Toaster', Toaster) + .mount('#app') diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts new file mode 100644 index 000000000..95448cd5f --- /dev/null +++ b/apps/ui-admin/src/modules/api.ts @@ -0,0 +1,174 @@ +import { defaultApiServerUrl, getServerAdminBootstrapContext } from './server-admin-context' + +export interface AdminUser { + id: string + name: string + email: string + emailVerified: boolean + image: string | null + createdAt: string + updatedAt: string + flux: number + stripeCustomerId: string | null +} + +export interface AdminMe { + role: 'admin' + user: Pick +} + +export interface AdminMetrics { + totalUsers: number + verifiedUsers: number + activeSessions: number + currentFlux: number + issuedFlux: number + llmRequests24h: number + llmFlux24h: number + adminSeats: number + grafanaEmbedUrl: string | null +} + +export interface FluxTransaction { + id: string + type: string + amount: number + balanceBefore: number + balanceAfter: number + description: string + metadata: unknown + createdAt: string +} + +export interface AdminUsersPage { + users: AdminUser[] + hasMore: boolean + nextOffset: number | null + total: number +} + +export interface AdminRouterConfigRequest { + mode?: 'merge' | 'reset' + dryRun?: boolean + slices?: Array> + defaults?: { + chatModel?: string + ttsModel?: string + ttsVoices?: Record> + } +} + +export interface AdminRouterConfigResult { + applied: Array> + invalidatedKeys: string[] + preview: Record +} + +export class AdminApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly payload: unknown, + ) { + super(message) + this.name = 'AdminApiError' + } +} + +export function apiServerUrl(): string { + return getServerAdminBootstrapContext()?.apiServerUrl ?? defaultApiServerUrl() +} + +export function signInUrl(): string { + const url = new URL('/auth/sign-in', apiServerUrl()) + url.searchParams.set('redirect', `${window.location.pathname}${window.location.search}`) + return url.toString() +} + +async function adminFetch(path: string, init: RequestInit = {}): Promise { + const endpoint = new URL(`/api/admin${path}`, apiServerUrl()) + const headers = new Headers(init.headers) + + if (init.body && !headers.has('Content-Type')) + headers.set('Content-Type', 'application/json') + + const response = await fetch(endpoint.toString(), { + ...init, + headers, + credentials: 'include', + }) + + let payload: unknown = null + try { + payload = await response.json() + } + catch { + payload = null + } + + if (!response.ok) { + const message = extractErrorMessage(payload) ?? `Admin API request failed (${response.status})` + throw new AdminApiError(message, response.status, payload) + } + + return payload as T +} + +function extractErrorMessage(payload: unknown): string | null { + if (!payload || typeof payload !== 'object') + return null + const maybe = payload as { message?: unknown, error?: unknown } + if (typeof maybe.message === 'string') + return maybe.message + if (typeof maybe.error === 'string') + return maybe.error + return null +} + +export const adminApi = { + me: () => adminFetch('/me'), + metrics: () => adminFetch('/metrics'), + users: (params: { query?: string, limit?: number, offset?: number, sortDirection?: string, sortKey?: string, status?: string }) => { + const query = new URLSearchParams() + if (params.query) + query.set('query', params.query) + if (params.limit != null) + query.set('limit', String(params.limit)) + if (params.offset != null) + query.set('offset', String(params.offset)) + if (params.sortDirection) + query.set('sortDirection', params.sortDirection) + if (params.sortKey) + query.set('sortKey', params.sortKey) + if (params.status) + query.set('status', params.status) + const suffix = query.toString() ? `?${query.toString()}` : '' + return adminFetch(`/users${suffix}`) + }, + user: (id: string) => adminFetch<{ user: AdminUser, recentFluxTransactions: FluxTransaction[] }>(`/users/${encodeURIComponent(id)}`), + grantUserFlux: (id: string, body: { amount: number, description: string, idempotencyKey?: string }) => + adminFetch<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string, idempotent: boolean }>(`/users/${encodeURIComponent(id)}/flux/grant`, { + method: 'POST', + body: JSON.stringify(body), + }), + setUserFlux: (id: string, body: { balance: number, description: string }) => + adminFetch<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string | null, changed: boolean }>(`/users/${encodeURIComponent(id)}/flux`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + fluxGrantPreview: (body: { amount: number, description: string, emails: string[], idempotencyKey?: string }) => + adminFetch('/flux-grants?dryRun=true', { + method: 'POST', + body: JSON.stringify(body), + }), + fluxGrant: (body: { amount: number, description: string, emails: string[], idempotencyKey?: string }) => + adminFetch('/flux-grants', { + method: 'POST', + body: JSON.stringify(body), + }), + applyRouterConfig: (body: AdminRouterConfigRequest, dryRun: boolean) => + adminFetch('/config/router', { + method: 'POST', + body: JSON.stringify({ ...body, dryRun }), + }), +} diff --git a/apps/ui-admin/src/modules/server-admin-context.ts b/apps/ui-admin/src/modules/server-admin-context.ts new file mode 100644 index 000000000..708fa287c --- /dev/null +++ b/apps/ui-admin/src/modules/server-admin-context.ts @@ -0,0 +1,36 @@ +export interface ServerAdminBootstrapContext { + apiServerUrl: string + currentUrl: string +} + +const SCRIPT_ID = 'airi-server-admin-context' + +let cachedContext: ServerAdminBootstrapContext | null | undefined + +export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext | null { + if (cachedContext !== undefined) + return cachedContext + + const element = document.getElementById(SCRIPT_ID) + if (!element) { + cachedContext = null + return cachedContext + } + + try { + const parsed = JSON.parse(element.textContent ?? '') as Partial + cachedContext = { + apiServerUrl: parsed.apiServerUrl ?? defaultApiServerUrl(), + currentUrl: parsed.currentUrl ?? window.location.href, + } + return cachedContext + } + catch { + cachedContext = null + return cachedContext + } +} + +export function defaultApiServerUrl(): string { + return import.meta.env.VITE_SERVER_URL || window.location.origin +} diff --git a/apps/ui-admin/src/pages/FluxPage.vue b/apps/ui-admin/src/pages/FluxPage.vue new file mode 100644 index 000000000..d7837265a --- /dev/null +++ b/apps/ui-admin/src/pages/FluxPage.vue @@ -0,0 +1,166 @@ + + +