From 4d28586d131ab472aef85ee7d25449289ad5cdc1 Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Tue, 21 Oct 2025 22:07:47 +0200 Subject: [PATCH] add ui code --- ui/.eslintrc.json | 11 + ui/.gitignore | 41 + ui/.prettierrc.json | 8 + ui/Dockerfile | 38 + ui/README.md | 36 + ui/app/_register/page.tsx | 172 + ui/app/data.json | 614 ++ ui/app/favicon.ico | Bin 0 -> 25931 bytes ui/app/globals.css | 136 + ui/app/layout.tsx | 35 + ui/app/login/page.tsx | 82 + ui/app/models/page.tsx | 258 + ui/app/page.tsx | 31 + ui/app/providers.tsx | 39 + ui/app/providers/page.tsx | 690 ++ ui/app/settings/page.tsx | 36 + ui/app/transactions/page.tsx | 353 + ui/app/unauthorized/page.tsx | 32 + ui/components.json | 21 + ui/components/AddModelForm.tsx | 319 + ui/components/ApiEndpointTester.tsx | 1675 +++ ui/components/CollectModelsDialog.tsx | 358 + ui/components/CostCalculator.tsx | 226 + ui/components/CostCalculatorDialog.tsx | 46 + ui/components/EditGroupForm.tsx | 364 + ui/components/EditModelForm.tsx | 289 + ui/components/ModelSelector.tsx | 1260 +++ ui/components/ModelTester.tsx | 451 + ui/components/app-sidebar.tsx | 95 + ui/components/chart-area-interactive.tsx | 289 + ui/components/data-table.tsx | 817 ++ ui/components/detailed-wallet-balance.tsx | 207 + ui/components/ecash-redeem.tsx | 202 + ui/components/nav-documents.tsx | 85 + ui/components/nav-main.tsx | 40 + ui/components/nav-secondary.tsx | 42 + ui/components/nav-user.tsx | 142 + ui/components/section-cards.tsx | 101 + .../settings/server-config-settings.tsx | 167 + ui/components/site-header.tsx | 48 + ui/components/ui/alert-dialog.tsx | 157 + ui/components/ui/alert.tsx | 66 + ui/components/ui/avatar.tsx | 53 + ui/components/ui/badge.tsx | 41 + ui/components/ui/button.tsx | 59 + ui/components/ui/card.tsx | 92 + ui/components/ui/carousel.tsx | 241 + ui/components/ui/chart.tsx | 353 + ui/components/ui/checkbox.tsx | 32 + ui/components/ui/collapsible.tsx | 33 + ui/components/ui/command.tsx | 177 + ui/components/ui/context-menu.tsx | 252 + ui/components/ui/dialog.tsx | 135 + ui/components/ui/dropdown-menu.tsx | 257 + ui/components/ui/form.tsx | 168 + ui/components/ui/hover-card.tsx | 44 + ui/components/ui/input-otp.tsx | 77 + ui/components/ui/input.tsx | 21 + ui/components/ui/label.tsx | 24 + ui/components/ui/menubar.tsx | 276 + ui/components/ui/navigation-menu.tsx | 168 + ui/components/ui/popover.tsx | 48 + ui/components/ui/progress.tsx | 31 + ui/components/ui/radio-group.tsx | 45 + ui/components/ui/resizable.tsx | 56 + ui/components/ui/scroll-area.tsx | 58 + ui/components/ui/select.tsx | 185 + ui/components/ui/separator.tsx | 28 + ui/components/ui/sheet.tsx | 139 + ui/components/ui/sidebar.tsx | 726 ++ ui/components/ui/skeleton.tsx | 13 + ui/components/ui/sonner.tsx | 25 + ui/components/ui/switch.tsx | 31 + ui/components/ui/table.tsx | 116 + ui/components/ui/tabs.tsx | 66 + ui/components/ui/textarea.tsx | 18 + ui/components/ui/toast.tsx | 130 + ui/components/ui/toggle-group.tsx | 73 + ui/components/ui/toggle.tsx | 47 + ui/components/ui/tooltip.tsx | 61 + ui/components/wallet-balance.tsx | 76 + ui/components/withdraw-modal.tsx | 260 + ui/eslint.config.mjs | 16 + ui/hooks/use-mobile.ts | 21 + ui/lib/api/client.ts | 156 + ui/lib/api/schemas/api-keys.ts | 32 + ui/lib/api/schemas/credits.ts | 82 + ui/lib/api/schemas/models.ts | 150 + ui/lib/api/schemas/organizations.ts | 28 + ui/lib/api/schemas/providers.ts | 29 + ui/lib/api/schemas/users.ts | 34 + ui/lib/api/services/admin.ts | 647 ++ ui/lib/api/services/api-keys.ts | 91 + ui/lib/api/services/api.ts | 84 + ui/lib/api/services/auth.ts | 130 + ui/lib/api/services/configuration.ts | 270 + ui/lib/api/services/models.ts | 509 + ui/lib/api/services/users.ts | 60 + ui/lib/api/services/wallet.ts | 124 + ui/lib/auth/AuthContext.tsx | 159 + ui/lib/auth/ProtectedRoute.tsx | 21 + ui/lib/hooks/useConfiguration.ts | 101 + ui/lib/hooks/useModels.ts | 48 + ui/lib/hooks/useUsers.ts | 120 + .../services/__tests__/costValidation.test.ts | 248 + ui/lib/services/costValidation.ts | 192 + ui/lib/utils.ts | 6 + ui/next.config.ts | 8 + ui/package-lock.json | 9086 +++++++++++++++++ ui/package.json | 87 + ui/pnpm-lock.yaml | 8488 +++++++++++++++ ui/postcss.config.mjs | 5 + ui/public/file.svg | 1 + ui/public/globe.svg | 1 + ui/public/next.svg | 1 + ui/public/vercel.svg | 1 + ui/public/window.svg | 1 + ui/tsconfig.json | 27 + 118 files changed, 35878 insertions(+) create mode 100644 ui/.eslintrc.json create mode 100644 ui/.gitignore create mode 100644 ui/.prettierrc.json create mode 100644 ui/Dockerfile create mode 100644 ui/README.md create mode 100644 ui/app/_register/page.tsx create mode 100644 ui/app/data.json create mode 100644 ui/app/favicon.ico create mode 100644 ui/app/globals.css create mode 100644 ui/app/layout.tsx create mode 100644 ui/app/login/page.tsx create mode 100644 ui/app/models/page.tsx create mode 100644 ui/app/page.tsx create mode 100644 ui/app/providers.tsx create mode 100644 ui/app/providers/page.tsx create mode 100644 ui/app/settings/page.tsx create mode 100644 ui/app/transactions/page.tsx create mode 100644 ui/app/unauthorized/page.tsx create mode 100644 ui/components.json create mode 100644 ui/components/AddModelForm.tsx create mode 100644 ui/components/ApiEndpointTester.tsx create mode 100644 ui/components/CollectModelsDialog.tsx create mode 100644 ui/components/CostCalculator.tsx create mode 100644 ui/components/CostCalculatorDialog.tsx create mode 100644 ui/components/EditGroupForm.tsx create mode 100644 ui/components/EditModelForm.tsx create mode 100644 ui/components/ModelSelector.tsx create mode 100644 ui/components/ModelTester.tsx create mode 100644 ui/components/app-sidebar.tsx create mode 100644 ui/components/chart-area-interactive.tsx create mode 100644 ui/components/data-table.tsx create mode 100644 ui/components/detailed-wallet-balance.tsx create mode 100644 ui/components/ecash-redeem.tsx create mode 100644 ui/components/nav-documents.tsx create mode 100644 ui/components/nav-main.tsx create mode 100644 ui/components/nav-secondary.tsx create mode 100644 ui/components/nav-user.tsx create mode 100644 ui/components/section-cards.tsx create mode 100644 ui/components/settings/server-config-settings.tsx create mode 100644 ui/components/site-header.tsx create mode 100644 ui/components/ui/alert-dialog.tsx create mode 100644 ui/components/ui/alert.tsx create mode 100644 ui/components/ui/avatar.tsx create mode 100644 ui/components/ui/badge.tsx create mode 100644 ui/components/ui/button.tsx create mode 100644 ui/components/ui/card.tsx create mode 100644 ui/components/ui/carousel.tsx create mode 100644 ui/components/ui/chart.tsx create mode 100644 ui/components/ui/checkbox.tsx create mode 100644 ui/components/ui/collapsible.tsx create mode 100644 ui/components/ui/command.tsx create mode 100644 ui/components/ui/context-menu.tsx create mode 100644 ui/components/ui/dialog.tsx create mode 100644 ui/components/ui/dropdown-menu.tsx create mode 100644 ui/components/ui/form.tsx create mode 100644 ui/components/ui/hover-card.tsx create mode 100644 ui/components/ui/input-otp.tsx create mode 100644 ui/components/ui/input.tsx create mode 100644 ui/components/ui/label.tsx create mode 100644 ui/components/ui/menubar.tsx create mode 100644 ui/components/ui/navigation-menu.tsx create mode 100644 ui/components/ui/popover.tsx create mode 100644 ui/components/ui/progress.tsx create mode 100644 ui/components/ui/radio-group.tsx create mode 100644 ui/components/ui/resizable.tsx create mode 100644 ui/components/ui/scroll-area.tsx create mode 100644 ui/components/ui/select.tsx create mode 100644 ui/components/ui/separator.tsx create mode 100644 ui/components/ui/sheet.tsx create mode 100644 ui/components/ui/sidebar.tsx create mode 100644 ui/components/ui/skeleton.tsx create mode 100644 ui/components/ui/sonner.tsx create mode 100644 ui/components/ui/switch.tsx create mode 100644 ui/components/ui/table.tsx create mode 100644 ui/components/ui/tabs.tsx create mode 100644 ui/components/ui/textarea.tsx create mode 100644 ui/components/ui/toast.tsx create mode 100644 ui/components/ui/toggle-group.tsx create mode 100644 ui/components/ui/toggle.tsx create mode 100644 ui/components/ui/tooltip.tsx create mode 100644 ui/components/wallet-balance.tsx create mode 100644 ui/components/withdraw-modal.tsx create mode 100644 ui/eslint.config.mjs create mode 100644 ui/hooks/use-mobile.ts create mode 100644 ui/lib/api/client.ts create mode 100644 ui/lib/api/schemas/api-keys.ts create mode 100644 ui/lib/api/schemas/credits.ts create mode 100644 ui/lib/api/schemas/models.ts create mode 100644 ui/lib/api/schemas/organizations.ts create mode 100644 ui/lib/api/schemas/providers.ts create mode 100644 ui/lib/api/schemas/users.ts create mode 100644 ui/lib/api/services/admin.ts create mode 100644 ui/lib/api/services/api-keys.ts create mode 100644 ui/lib/api/services/api.ts create mode 100644 ui/lib/api/services/auth.ts create mode 100644 ui/lib/api/services/configuration.ts create mode 100644 ui/lib/api/services/models.ts create mode 100644 ui/lib/api/services/users.ts create mode 100644 ui/lib/api/services/wallet.ts create mode 100644 ui/lib/auth/AuthContext.tsx create mode 100644 ui/lib/auth/ProtectedRoute.tsx create mode 100644 ui/lib/hooks/useConfiguration.ts create mode 100644 ui/lib/hooks/useModels.ts create mode 100644 ui/lib/hooks/useUsers.ts create mode 100644 ui/lib/services/__tests__/costValidation.test.ts create mode 100644 ui/lib/services/costValidation.ts create mode 100644 ui/lib/utils.ts create mode 100644 ui/next.config.ts create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/pnpm-lock.yaml create mode 100644 ui/postcss.config.mjs create mode 100644 ui/public/file.svg create mode 100644 ui/public/globe.svg create mode 100644 ui/public/next.svg create mode 100644 ui/public/vercel.svg create mode 100644 ui/public/window.svg create mode 100644 ui/tsconfig.json diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 00000000..2988de07 --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "next", + "next/core-web-vitals", + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "plugins": ["react", "@typescript-eslint"] +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json new file mode 100644 index 00000000..f5a575dc --- /dev/null +++ b/ui/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "semi": true, + "tabWidth": 2, + "singleQuote": true, + "jsxSingleQuote": true, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 00000000..732ba1fc --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,38 @@ +FROM node:23-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN npm i + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/ui/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/ui/app/_register/page.tsx b/ui/app/_register/page.tsx new file mode 100644 index 00000000..f88bf940 --- /dev/null +++ b/ui/app/_register/page.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useAuth } from '@/lib/auth/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import Link from 'next/link'; +import { ArrowUpCircleIcon } from 'lucide-react'; +import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth'; + +export default function NostrRegisterPage() { + const router = useRouter(); + const { connectNostr } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + npub: '', + name: '', + }); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleNostrConnect = async () => { + setIsLoading(true); + try { + const publicKey = await connectNostr(); + if (publicKey) { + setFormData((prev) => ({ ...prev, npub: publicKey })); + toast.success( + 'Nostr connected. Please enter your name to complete registration.' + ); + } else { + toast.error( + 'Failed to connect. Please make sure your Nostr extension is installed and enabled.' + ); + } + } catch (error) { + console.error('Nostr connection error:', error); + toast.error('Failed to connect to Nostr. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.npub || formData.npub.length < 10) { + toast.error('Please enter a valid Nostr public key'); + return; + } + + if (!formData.name) { + toast.error('Please enter your name'); + return; + } + + setIsLoading(true); + try { + // Register the user + const result = await registerUser(formData); + console.log('Registration successful:', result); + toast.success('Account created successfully'); + router.push('/login'); + } catch (error) { + console.error('Registration error:', error); + toast.error('Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+
+
+ +
+ +
+ Routstr + +

Create an Account

+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+ + +
+
+ + +
+ +
+
+ + Or + +
+
+ +
+
+
+
+ By clicking create account, you agree to our{' '} + Terms of Service and{' '} + Privacy Policy. +
+
+
+
+ ); +} diff --git a/ui/app/data.json b/ui/app/data.json new file mode 100644 index 00000000..ec087364 --- /dev/null +++ b/ui/app/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/ui/app/favicon.ico b/ui/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/ui/app/globals.css b/ui/app/globals.css new file mode 100644 index 00000000..86093a5c --- /dev/null +++ b/ui/app/globals.css @@ -0,0 +1,136 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.423); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.923 0.003 48.717); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.423); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Custom animations */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx new file mode 100644 index 00000000..a20063b3 --- /dev/null +++ b/ui/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { Providers } from './providers'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Routstr', + description: 'Routstr model management', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx new file mode 100644 index 00000000..93b911b8 --- /dev/null +++ b/ui/app/login/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { adminLogin } from '@/lib/api/services/auth'; +import { ConfigurationService } from '@/lib/api/services/configuration'; +import { toast } from 'sonner'; + +export default function AdminLoginPage() { + const router = useRouter(); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (ConfigurationService.isTokenValid()) { + router.push('/'); + } + }, [router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error('Please enter your password'); + return; + } + + setIsLoading(true); + try { + await adminLogin(password); + toast.success('Successfully logged in'); + router.push('/'); + } catch (error) { + console.error('Login error:', error); + toast.error('Invalid password. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + Admin Login + + + Enter your admin password to access the dashboard + + + +
+
+ setPassword(e.target.value)} + disabled={isLoading} + autoFocus + required + /> +
+ +
+
+
+
+ ); +} diff --git a/ui/app/models/page.tsx b/ui/app/models/page.tsx new file mode 100644 index 00000000..23f60c2a --- /dev/null +++ b/ui/app/models/page.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { ModelSelector } from '@/components/ModelSelector'; +import { ModelTester } from '@/components/ModelTester'; +import { ApiEndpointTester } from '@/components/ApiEndpointTester'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useQuery } from '@tanstack/react-query'; +import { AdminService } from '@/lib/api/services/admin'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AlertCircle, Users, Globe } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { useMemo } from 'react'; + +export default function ModelsPage() { + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useQuery({ + queryKey: ['admin-models-with-providers'], + queryFn: () => AdminService.getModelsWithProviders(), + refetchOnWindowFocus: false, + }); + + const { models = [], groups = [] } = modelsData || {}; + + const groupedModels = useMemo(() => { + if (!models) return {}; + + return models.reduce>((acc, model) => { + const provider = model.provider; + if (!acc[provider]) { + acc[provider] = []; + } + acc[provider].push(model); + return acc; + }, {}); + }, [models]); + + const groupDataMap = useMemo(() => { + return new Map(groups.map((group) => [group.provider, group])); + }, [groups]); + + const providerInfo = useMemo(() => { + return Object.entries(groupedModels).map(([provider, providerModels]) => { + const groupData = groupDataMap.get(provider); + const activeModels = providerModels.filter((m) => !m.soft_deleted).length; + const totalModels = providerModels.length; + + return { + provider, + activeModels, + totalModels, + groupData, + hasGroupUrl: !!groupData?.group_url, + hasGroupApiKey: !!groupData?.group_api_key, + }; + }); + }, [groupedModels, groupDataMap]); + + return ( + + + + +
+
+
+

+ Model Management & API Testing +

+
+ + + + Manage Models + {/*Basic Testing + API Endpoints */} + + + +
+ Manage your AI models organized by provider groups. Configure + API keys, and organize models by provider groups. +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models. Please try refreshing the page. + + + ) : ( + +
+ {/* Provider Tabs Navigation */} +
+ + + + All Models + + {models.length} + + + {providerInfo.map( + ({ provider, activeModels, totalModels }) => ( + + + {provider} +
+ + {activeModels}/{totalModels} + +
+
+ ) + )} +
+
+ + {/* All Models Tab */} + +
+
+ Overview of all models across all provider groups. +
+ +
+
+ + {Object.entries(groupedModels).map( + ([provider, providerModels]) => { + const groupData = groupDataMap.get(provider); + + return ( + +
+
+
+

+ + {provider} +

+
+ {providerModels.filter( + (m) => m.soft_deleted + ).length > 0 && ( + + { + providerModels.filter( + (m) => m.soft_deleted + ).length + }{' '} + soft deleted + + )} + {groupData?.group_url && ( + + + {groupData.group_url} + + )} +
+
+
+ +
+
+ ); + } + )} +
+
+ )} +
+ + +
+ Test model credentials and connectivity with basic chat + completion requests through the secure proxy (resolves CORS + and Docker network issues). Models can be tested even without + API keys configured (useful for free models or when + authentication is handled elsewhere). +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for testing. Please try refreshing + the page. + + + ) : ( + + )} +
+ + +
+ Comprehensive testing of all OpenAI API endpoints including + chat completions, embeddings, image generation, audio + synthesis, and model listing through the secure proxy + (resolves CORS and Docker network issues). Models can be + tested with or without API keys configured. +
+ + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for API testing. Please try + refreshing the page. + + + ) : ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx new file mode 100644 index 00000000..fde61885 --- /dev/null +++ b/ui/app/page.tsx @@ -0,0 +1,31 @@ +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; + +export default function Page() { + return ( + + + + +
+
+

+ Admin Dashboard +

+

+ Monitor and manage wallet balances +

+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx new file mode 100644 index 00000000..af801e33 --- /dev/null +++ b/ui/app/providers.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useState, type ReactNode } from 'react'; +import { Toaster } from 'sonner'; +import { AuthProvider } from '@/lib/auth/AuthContext'; +import { ProtectedRoute } from '@/lib/auth/ProtectedRoute'; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + retry: 2, + }, + }, + }) + ); + + return ( + + + + {children} + + + + + + ); +} diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx new file mode 100644 index 00000000..64339515 --- /dev/null +++ b/ui/app/providers/page.tsx @@ -0,0 +1,690 @@ +'use client'; + +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + AdminService, + UpstreamProvider, + CreateUpstreamProvider, + UpdateUpstreamProvider, +} from '@/lib/api/services/admin'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertCircle, + Plus, + Pencil, + Trash2, + Server, + Eye, + EyeOff, + Database, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +export default function ProvidersPage() { + const queryClient = useQueryClient(); + const [editingProvider, setEditingProvider] = + useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [showApiKey, setShowApiKey] = useState(null); + const [expandedProviders, setExpandedProviders] = useState>( + new Set() + ); + const [viewingModels, setViewingModels] = useState(null); + + const [formData, setFormData] = useState({ + provider_type: 'openrouter', + base_url: '', + api_key: '', + api_version: null, + enabled: true, + }); + + const { + data: providers = [], + isLoading, + error, + } = useQuery({ + queryKey: ['upstream-providers'], + queryFn: () => AdminService.getUpstreamProviders(), + refetchOnWindowFocus: false, + }); + + const { data: providerModels, isLoading: isLoadingModels } = useQuery({ + queryKey: ['provider-models', viewingModels], + queryFn: () => + viewingModels + ? AdminService.getProviderModels(viewingModels) + : Promise.resolve(null), + enabled: !!viewingModels, + refetchOnWindowFocus: false, + }); + + const createMutation = useMutation({ + mutationFn: (data: CreateUpstreamProvider) => + AdminService.createUpstreamProvider(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + setIsCreateDialogOpen(false); + toast.success('Provider created successfully'); + resetForm(); + }, + onError: (error: Error) => { + toast.error(`Failed to create provider: ${error.message}`); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateUpstreamProvider }) => + AdminService.updateUpstreamProvider(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + setIsEditDialogOpen(false); + setEditingProvider(null); + toast.success('Provider updated successfully'); + }, + onError: (error: Error) => { + toast.error(`Failed to update provider: ${error.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => AdminService.deleteUpstreamProvider(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + toast.success('Provider deleted successfully'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete provider: ${error.message}`); + }, + }); + + const resetForm = () => { + setFormData({ + provider_type: 'openrouter', + base_url: '', + api_key: '', + api_version: null, + enabled: true, + }); + }; + + const handleCreate = () => { + createMutation.mutate(formData); + }; + + const handleEdit = (provider: UpstreamProvider) => { + setEditingProvider(provider); + setFormData({ + provider_type: provider.provider_type, + base_url: provider.base_url, + api_key: '', + api_version: provider.api_version || null, + enabled: provider.enabled, + }); + setIsEditDialogOpen(true); + }; + + const handleUpdate = () => { + if (!editingProvider) return; + const updateData: UpdateUpstreamProvider = { + provider_type: formData.provider_type, + base_url: formData.base_url, + api_version: formData.api_version, + enabled: formData.enabled, + }; + if (formData.api_key) { + updateData.api_key = formData.api_key; + } + updateMutation.mutate({ id: editingProvider.id, data: updateData }); + }; + + const handleDelete = (id: number) => { + if (confirm('Are you sure you want to delete this provider?')) { + deleteMutation.mutate(id); + } + }; + + const getDefaultBaseUrl = (type: string) => { + const defaults: Record = { + openrouter: 'https://openrouter.ai/api/v1', + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com/v1', + azure: '', + generic: '', + }; + return defaults[type] || ''; + }; + + const toggleProviderExpansion = (providerId: number) => { + const newExpanded = new Set(expandedProviders); + if (newExpanded.has(providerId)) { + newExpanded.delete(providerId); + } else { + newExpanded.add(providerId); + } + setExpandedProviders(newExpanded); + if (!newExpanded.has(providerId)) { + setViewingModels(null); + } else { + setViewingModels(providerId); + } + }; + + return ( + + + + +
+
+
+
+

+ Upstream Providers +

+

+ Manage your AI provider connections and credentials +

+
+ + + + + + + Add Upstream Provider + + Configure a new AI provider connection + + +
+
+ + +
+
+ + + setFormData({ ...formData, base_url: e.target.value }) + } + placeholder='https://api.example.com/v1' + /> +
+
+ + + setFormData({ ...formData, api_key: e.target.value }) + } + placeholder='sk-...' + /> +
+ {formData.provider_type === 'azure' && ( +
+ + + setFormData({ + ...formData, + api_version: e.target.value || null, + }) + } + placeholder='2024-02-15-preview' + /> +
+ )} +
+ + setFormData({ ...formData, enabled: checked }) + } + /> + +
+
+ + + + +
+
+
+ + {isLoading ? ( +
+ + +
+ ) : error ? ( + + + + Failed to load providers. Please try refreshing the page. + + + ) : providers.length === 0 ? ( + + + +

+ No providers configured +

+

+ Get started by adding your first upstream provider +

+ +
+
+ ) : ( +
+ {providers.map((provider) => ( + + +
+
+
+ + {provider.provider_type} + + + {provider.enabled ? 'Enabled' : 'Disabled'} + +
+ + {provider.base_url} + +
+
+ + + +
+
+
+ +
+
+
+ + Provider ID: + + {provider.id} +
+ {provider.api_version && ( +
+ + API Version: + + + {provider.api_version} + +
+ )} +
+ + API Key: + +
+ + {showApiKey === provider.id + ? provider.api_key || '[REDACTED]' + : '••••••••'} + + +
+
+
+ + {expandedProviders.has(provider.id) && ( +
+ {isLoadingModels && + viewingModels === provider.id ? ( +
+ + +
+ ) : providerModels && + viewingModels === provider.id ? ( + + + + Database Models + + {providerModels.db_models.length} + + + + Remote Models + + {providerModels.remote_models.length} + + + + + {providerModels.db_models.length === 0 ? ( +
+ No database models configured +
+ ) : ( +
+ {providerModels.db_models.map((model) => ( +
+
+
+ + {model.id} + + + {model.enabled + ? 'Enabled' + : 'Disabled'} + +
+
+ {model.description || model.name} +
+
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
+ ))} +
+ )} +
+ + {providerModels.remote_models.length === 0 ? ( +
+ No remote models available +
+ ) : ( +
+ {providerModels.remote_models.map( + (model) => ( +
+
+
+ {model.id} +
+
+ {model.description || + model.name} +
+
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
+ ) + )} +
+ )} +
+
+ ) : null} +
+ )} +
+
+
+ ))} +
+ )} +
+
+ + + + + Edit Upstream Provider + + Update provider configuration + + +
+
+ + +
+
+ + + setFormData({ ...formData, base_url: e.target.value }) + } + placeholder='https://api.example.com/v1' + /> +
+
+ + + setFormData({ ...formData, api_key: e.target.value }) + } + placeholder='Leave blank to keep current' + /> +
+ {formData.provider_type === 'azure' && ( +
+ + + setFormData({ + ...formData, + api_version: e.target.value || null, + }) + } + placeholder='2024-02-15-preview' + /> +
+ )} +
+ + setFormData({ ...formData, enabled: checked }) + } + /> + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx new file mode 100644 index 00000000..e2b512ed --- /dev/null +++ b/ui/app/settings/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import * as React from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ServerConfigSettings } from '@/components/settings/server-config-settings'; +import { SiteHeader } from '@/components/site-header'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { Toaster } from 'sonner'; + +export default function SettingsPage() { + return ( + + + + +
+
+
+

Settings

+
+ + + Server Configuration + + + + + +
+
+
+ +
+ ); +} diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx new file mode 100644 index 00000000..973c30bb --- /dev/null +++ b/ui/app/transactions/page.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useState } from 'react'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { useQuery } from '@tanstack/react-query'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertCircle, + Copy, + RefreshCw, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { toast } from 'sonner'; +import { apiClient } from '@/lib/api/client'; + +interface Transaction { + id: string; + created_at: string; + token: string; + amount: string; +} + +interface PaginatedTransactionsResponse { + transactions: Transaction[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +const TransactionService = { + getAllTransactions: async (): Promise => { + try { + const response = await apiClient.get('/api/transactions'); + return response || []; + } catch (error) { + console.error('Failed to fetch transactions:', error); + throw new Error('Failed to fetch transactions'); + } + }, + + getPaginatedTransactions: async ( + page: number, + perPage: number + ): Promise => { + try { + const response = await apiClient.get( + `/api/transactions/paginated/${page}/${perPage}` + ); + return response; + } catch (error) { + console.error('Failed to fetch paginated transactions:', error); + throw new Error('Failed to fetch paginated transactions'); + } + }, + + getRecentTransactions: async (limit: number): Promise => { + try { + const response = await apiClient.get( + `/api/transactions/recent/${limit}` + ); + return response || []; + } catch (error) { + console.error('Failed to fetch recent transactions:', error); + throw new Error('Failed to fetch recent transactions'); + } + }, +}; + +export default function TransactionsPage() { + const [currentPage, setCurrentPage] = useState(1); + const perPage = 20; + + // Fetch paginated transactions data + const { + data: paginationData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['transactions', currentPage, perPage], + queryFn: () => + TransactionService.getPaginatedTransactions(currentPage, perPage), + refetchOnWindowFocus: false, + retry: 1, + staleTime: 30000, // 30 seconds + }); + + const transactions = paginationData?.transactions || []; + const totalPages = paginationData?.total_pages || 0; + const total = paginationData?.total || 0; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const formatAmount = (amount: string) => { + return `${parseInt(amount).toLocaleString()} msats`; + }; + + const truncateToken = (token: string) => { + if (token.length <= 20) return token; + return `${token.slice(0, 10)}...${token.slice(-10)}`; + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success('Token copied to clipboard!'); + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error('Failed to copy token'); + } + }; + + const goToPage = (page: number) => { + if (page >= 1 && page <= totalPages) { + setCurrentPage(page); + } + }; + + const goToPrevious = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const goToNext = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + return ( + + + + + +
+
+
+
+

+ Transaction History +

+

+ View all Cashu token transactions processed by the system +

+
+ +
+ + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : error ? ( + + + + Failed to load transactions.{' '} + {error instanceof Error + ? error.message + : 'Please check if the server is running and try refreshing the page.'} + + + ) : transactions.length === 0 ? ( +
+

+ No transactions found. +

+
+ ) : ( +
+
+
+ Showing {(currentPage - 1) * perPage + 1} to{' '} + {Math.min(currentPage * perPage, total)} of {total}{' '} + transactions +
+
+ Page {currentPage} of {totalPages} +
+
+ +
+ + + + ID + Date & Time + Amount + + Cashu Token + + Actions + + + + {transactions.map((transaction) => ( + + + {transaction.id.slice(0, 8)} + + +
+ {formatDate(transaction.created_at)} +
+
+ + + {formatAmount(transaction.amount)} + + + +
+ + +

+ {truncateToken(transaction.token)} +

+
+ +

+ {transaction.token} +

+
+
+
+
+ + + +
+ ))} +
+
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ Page {currentPage} of {totalPages} +
+
+ + + {/* Page Numbers */} +
+ {Array.from( + { length: Math.min(5, totalPages) }, + (_, i) => { + const pageNumber = + currentPage <= 3 + ? i + 1 + : currentPage >= totalPages - 2 + ? totalPages - 4 + i + : currentPage - 2 + i; + + if (pageNumber < 1 || pageNumber > totalPages) + return null; + + return ( + + ); + } + )} +
+ + +
+
+ )} +
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/app/unauthorized/page.tsx b/ui/app/unauthorized/page.tsx new file mode 100644 index 00000000..2fe8f4bc --- /dev/null +++ b/ui/app/unauthorized/page.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; +import { ShieldAlertIcon } from 'lucide-react'; + +export default function UnauthorizedPage() { + const router = useRouter(); + + return ( +
+
+ + +

Access Denied

+ +

+ You don't have permission to access this page. Please contact + your administrator if you believe this is an error. +

+ +
+ + + +
+
+
+ ); +} diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..3d296c4f --- /dev/null +++ b/ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/ui/components/AddModelForm.tsx b/ui/components/AddModelForm.tsx new file mode 100644 index 00000000..005fdaab --- /dev/null +++ b/ui/components/AddModelForm.tsx @@ -0,0 +1,319 @@ +'use client'; + +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Plus, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface AddModelFormProps { + onModelAdd: (model: ManualModel) => void; + onCancel?: () => void; + isOpen: boolean; +} + +export function AddModelForm({ + onModelAdd, + onCancel, + isOpen, +}: AddModelFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any + defaultValues: { + name: '', + full_name: '', + input_cost: 0, + output_cost: 0, + provider: '', + modelType: 'text' as const, + description: '', + contextLength: undefined, + }, + }); + + const onSubmit = async (data: ManualModel) => { + setIsSubmitting(true); + try { + await onModelAdd(data); + toast.success('Model added successfully!'); + form.reset(); + onCancel?.(); + } catch (error) { + toast.error('Failed to add model. Please try again.'); + console.error('Error adding model:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + form.reset(); + onCancel?.(); + } + }; + + return ( + + + + + + Add New Model + + + Manually add a new AI model to your collection + + +
+ +
+ ( + + Model Name * + + + + + Display name for the model + + + + )} + /> + + ( + + Provider * + + + + + AI model provider or company + + + + )} + /> +
+ +
+ ( + + Input Cost (per 1M tokens) * + + { + const value = parseFloat(e.target.value) || 0; + const rounded = Math.round(value * 1000) / 1000; + field.onChange(rounded); + }} + className='w-full' + /> + + + Cost in USD per 1,000,000 input tokens (max 3 decimals) + + + + )} + /> + + ( + + Output Cost (per 1M tokens) * + + { + const value = parseFloat(e.target.value) || 0; + const rounded = Math.round(value * 1000) / 1000; + field.onChange(rounded); + }} + className='w-full' + /> + + + Cost in USD per 1,000,000 output tokens (max 3 decimals) + + + + )} + /> +
+ +
+ ( + + Model Type + + + Type of AI model functionality + + + + )} + /> + + ( + + Context Length + + { + const val = parseInt(e.target.value); + field.onChange( + isNaN(val) || val === 0 ? undefined : val + ); + }} + className='w-full' + /> + + + Maximum context length in tokens (optional, leave empty + for default) + + + + )} + /> +
+ + ( + + Description + +