feat(ai): add AI management page and sidebar link
This commit is contained in:
parent
8ad3f091aa
commit
446152648f
3 changed files with 329 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
|||
312
src/routes/admin/ai-management.tsx
Normal file
312
src/routes/admin/ai-management.tsx
Normal file
|
|
@ -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<string, string> {
|
||||
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 (
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">AI Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage AI plans, feature costs, user credits, and usage.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
class={activeTab() === tab.key ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={msg()}>
|
||||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;font-size:14px;color:#15803d;font-weight:600">{msg()}</div>
|
||||
</Show>
|
||||
<Show when={err()}>
|
||||
<div style="background:#fee2e2;border:1px solid #fecaca;border-radius:6px;padding:10px 14px;font-size:14px;color:#b91c1c;font-weight:600">{err()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'plans'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">AI Plans</h2>
|
||||
<Show when={plans.loading}><p class="text-sm text-gray-500">Loading plans...</p></Show>
|
||||
<Show when={plans.error}><p class="text-sm text-red-600">Failed to load plans</p></Show>
|
||||
<Show when={plans()}>
|
||||
{(data) => (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Monthly Credits</th>
|
||||
<th>Daily Actions</th>
|
||||
<th>Models</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={data().plans || []}>
|
||||
{(plan: any) => (
|
||||
<tr>
|
||||
<td class="font-semibold">{plan.code}</td>
|
||||
<td>{plan.name}</td>
|
||||
<td>{plan.monthly_credits}</td>
|
||||
<td>{plan.daily_action_limit}</td>
|
||||
<td class="text-xs text-gray-500">{(plan.allowed_models || []).join(', ')}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'features'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">AI Feature Costs</h2>
|
||||
<Show when={features.loading}><p class="text-sm text-gray-500">Loading features...</p></Show>
|
||||
<Show when={features.error}><p class="text-sm text-red-600">Failed to load features</p></Show>
|
||||
<Show when={features()}>
|
||||
{(data) => (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature Code</th>
|
||||
<th>Display Name</th>
|
||||
<th>Default Model</th>
|
||||
<th>Credit Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={data().features || []}>
|
||||
{(feat: any) => (
|
||||
<tr>
|
||||
<td class="font-semibold">{feat.feature_code}</td>
|
||||
<td>{feat.display_name}</td>
|
||||
<td>{feat.default_model}</td>
|
||||
<td>{feat.credit_cost}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'user'}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Update User Plan</h2>
|
||||
<form onSubmit={handleUpdatePlan} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">User ID</label>
|
||||
<input type="text" required value={userId()} onInput={(e) => setUserId(e.currentTarget.value)} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Plan Code</label>
|
||||
<select value={planCode()} onChange={(e) => setPlanCode(e.currentTarget.value)} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box">
|
||||
<option value="">Select plan</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" type="submit" disabled={loading()}>{loading() ? 'Updating...' : 'Update Plan'}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Add AI Credits</h2>
|
||||
<form onSubmit={handleAddCredits} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">User ID</label>
|
||||
<input type="text" required value={userId()} onInput={(e) => setUserId(e.currentTarget.value)} style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Credits</label>
|
||||
<input type="number" min="1" required value={creditAmount()} onInput={(e) => 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" />
|
||||
</div>
|
||||
<button class="btn-primary" type="submit" disabled={loading()}>{loading() ? 'Adding...' : 'Add Credits'}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'usage'}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">User Usage Lookup</h2>
|
||||
<UsageLookup />
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageLookup() {
|
||||
const [uid, setUid] = createSignal('');
|
||||
const [usage, setUsage] = createSignal<any>(null);
|
||||
const [logs, setLogs] = createSignal<any[]>([]);
|
||||
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 (
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;gap:10px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter User ID..."
|
||||
value={uid()}
|
||||
onInput={(e) => 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"
|
||||
/>
|
||||
<button class="btn-primary" onClick={handleSearch} disabled={loading()}>{loading() ? 'Searching...' : 'Search'}</button>
|
||||
</div>
|
||||
<Show when={err()}><p class="text-sm text-red-600">{err()}</p></Show>
|
||||
<Show when={usage()}>
|
||||
{(u) => (
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Remaining credits: <span class="font-semibold">{u().remaining_credits}</span></p>
|
||||
<p class="text-sm text-gray-600">Daily actions: <span class="font-semibold">{u().remaining_daily_actions}/{u().daily_action_limit}</span></p>
|
||||
<p class="text-sm text-gray-600">Plan: <span class="font-semibold">{u().plan_name}</span></p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={logs().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr><th>Type</th><th>Credits</th><th>Balance After</th><th>Source</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={logs()}>
|
||||
{(tx: any) => (
|
||||
<tr>
|
||||
<td>{tx.transaction_type}</td>
|
||||
<td>{tx.credits}</td>
|
||||
<td>{tx.balance_after}</td>
|
||||
<td>{tx.source}</td>
|
||||
<td>{new Date(tx.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue