diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 7241470..cb1cb2c 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -12,7 +12,10 @@ concurrency: jobs: build: - runs-on: self-hosted + runs-on: docker-ready + env: + DOCKER_HOST: tcp://127.0.0.1:2375 + DOCKER_BUILDKIT: "1" steps: - name: Checkout uses: actions/checkout@v4 @@ -22,6 +25,12 @@ jobs: - name: Set up Docker Buildx run: | set -euo pipefail + for attempt in $(seq 1 30); do + if docker version >/dev/null 2>&1; then + break + fi + sleep 2 + done docker version docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder docker buildx inspect --bootstrap diff --git a/src/components/AdminSidebar.tsx b/src/components/AdminSidebar.tsx index e08717b..8a8e340 100644 --- a/src/components/AdminSidebar.tsx +++ b/src/components/AdminSidebar.tsx @@ -37,6 +37,7 @@ import { Megaphone, Bell, Video, + Sparkles, } from "lucide-solid"; type NavItem = { @@ -332,6 +333,12 @@ const GROUPS: NavItem[][] = [ icon: Receipt, moduleKeys: ["LEDGER", "LEDGER_MANAGEMENT"], }, + { + href: "/admin/ai-management", + label: "AI Management", + icon: Sparkles, + moduleKeys: ["AI_MANAGEMENT", "AI"], + }, ], ]; diff --git a/src/routes/admin/ai-management.tsx b/src/routes/admin/ai-management.tsx new file mode 100644 index 0000000..852e693 --- /dev/null +++ b/src/routes/admin/ai-management.tsx @@ -0,0 +1,312 @@ +import { createSignal, Show, For, createResource } from 'solid-js'; + +const API = '/api/admin/ai'; + +function getToken(): string { + return typeof sessionStorage !== 'undefined' + ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' + : ''; +} + +function authHeaders(): Record { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), + }; +} + +async function fetchJson(path: string, opts?: RequestInit) { + const res = await fetch(`${API}${path}`, { + ...opts, + credentials: 'include', + headers: authHeaders(), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; +} + +export default function AiManagementPage() { + const [activeTab, setActiveTab] = createSignal<'plans' | 'features' | 'user' | 'usage'>('plans'); + const [userId, setUserId] = createSignal(''); + const [planCode, setPlanCode] = createSignal(''); + const [creditAmount, setCreditAmount] = createSignal(10); + const [loading, setLoading] = createSignal(false); + const [msg, setMsg] = createSignal(''); + const [err, setErr] = createSignal(''); + + const [plans] = createResource(() => activeTab() === 'plans', () => fetchJson('/plans')); + const [features] = createResource(() => activeTab() === 'features', () => fetchJson('/features')); + + const handleUpdatePlan = async (e: Event) => { + e.preventDefault(); + setLoading(true); + setMsg(''); + setErr(''); + try { + await fetchJson(`/users/${userId().trim()}/plan`, { + method: 'POST', + body: JSON.stringify({ plan_code: planCode() }), + }); + setMsg(`Plan updated to ${planCode()} for user ${userId()}`); + } catch (e: any) { + setErr(e.message); + } finally { + setLoading(false); + } + }; + + const handleAddCredits = async (e: Event) => { + e.preventDefault(); + setLoading(true); + setMsg(''); + setErr(''); + try { + await fetchJson(`/users/${userId().trim()}/credits`, { + method: 'POST', + body: JSON.stringify({ credits: creditAmount(), source: 'admin', description: 'Manual admin credit grant' }), + }); + setMsg(`${creditAmount()} AI credits added to user ${userId()}`); + } catch (e: any) { + setErr(e.message); + } finally { + setLoading(false); + } + }; + + const tabs: { key: 'plans' | 'features' | 'user' | 'usage'; label: string }[] = [ + { key: 'plans', label: 'Plans' }, + { key: 'features', label: 'Features' }, + { key: 'user', label: 'User Management' }, + { key: 'usage', label: 'Usage Lookup' }, + ]; + + return ( +
+
+

AI Management

+

Manage AI plans, feature costs, user credits, and usage.

+
+ +
+ + {(tab) => ( + + )} + +
+ + +
{msg()}
+
+ +
{err()}
+
+ + +
+

AI Plans

+

Loading plans...

+

Failed to load plans

+ + {(data) => ( +
+ + + + + + + + + + + + + {(plan: any) => ( + + + + + + + + )} + + +
CodeNameMonthly CreditsDaily ActionsModels
{plan.code}{plan.name}{plan.monthly_credits}{plan.daily_action_limit}{(plan.allowed_models || []).join(', ')}
+
+ )} +
+
+
+ + +
+

AI Feature Costs

+

Loading features...

+

Failed to load features

+ + {(data) => ( +
+ + + + + + + + + + + + {(feat: any) => ( + + + + + + + )} + + +
Feature CodeDisplay NameDefault ModelCredit Cost
{feat.feature_code}{feat.display_name}{feat.default_model}{feat.credit_cost}
+
+ )} +
+
+
+ + +
+
+

Update User Plan

+
+
+ + setUserId(e.currentTarget.value)} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" /> +
+
+ + +
+ +
+
+ +
+

Add AI Credits

+
+
+ + setUserId(e.currentTarget.value)} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" /> +
+
+ + setCreditAmount(Number(e.currentTarget.value))} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" /> +
+ +
+
+
+
+ + +
+

User Usage Lookup

+ +
+
+
+ ); +} + +function UsageLookup() { + const [uid, setUid] = createSignal(''); + const [usage, setUsage] = createSignal(null); + const [logs, setLogs] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [err, setErr] = createSignal(''); + + const handleSearch = async () => { + const id = uid().trim(); + if (!id) return; + setLoading(true); + setErr(''); + try { + const [usageData, logsData] = await Promise.all([ + fetchJson(`/users/${id}/usage`), + fetchJson(`/users/${id}/transactions`), + ]); + setUsage(usageData); + setLogs(logsData.transactions || []); + } catch (e: any) { + setErr(e.message); + setUsage(null); + setLogs([]); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ setUid(e.currentTarget.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + style="flex:1;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> + +
+

{err()}

+ + {(u) => ( +
+

Remaining credits: {u().remaining_credits}

+

Daily actions: {u().remaining_daily_actions}/{u().daily_action_limit}

+

Plan: {u().plan_name}

+
+ )} +
+ 0}> +
+ + + + + + + {(tx: any) => ( + + + + + + + + )} + + +
TypeCreditsBalance AfterSourceDate
{tx.transaction_type}{tx.credits}{tx.balance_after}{tx.source}{new Date(tx.created_at).toLocaleString()}
+
+
+
+ ); +}