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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: self-hosted
|
runs-on: docker-ready
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: tcp://127.0.0.1:2375
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -22,6 +25,12 @@ jobs:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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 version
|
||||||
docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder
|
docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder
|
||||||
docker buildx inspect --bootstrap
|
docker buildx inspect --bootstrap
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Bell,
|
Bell,
|
||||||
Video,
|
Video,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
|
|
@ -332,6 +333,12 @@ const GROUPS: NavItem[][] = [
|
||||||
icon: Receipt,
|
icon: Receipt,
|
||||||
moduleKeys: ["LEDGER", "LEDGER_MANAGEMENT"],
|
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