Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use client';

import { useState, useEffect, useCallback } from 'react';
import {
getUsageMetrics,
type UsagePeriod,
type UsageMetrics,
} from '@actions/hackbot/getUsageMetrics';

const PERIODS: { value: UsagePeriod; label: string }[] = [
{ value: '24h', label: 'Last 24 h' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
];

function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}

export default function HackbotUsageMetrics() {
const [period, setPeriod] = useState<UsagePeriod>('24h');
const [metrics, setMetrics] = useState<UsageMetrics | null>(null);
const [loading, setLoading] = useState(true);

const load = useCallback(async (p: UsagePeriod) => {
setLoading(true);
try {
setMetrics(await getUsageMetrics(p));
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load(period);
}, [period, load]);

const uncachedTokens = metrics
? metrics.totalPromptTokens - metrics.totalCachedTokens
: 0;
const hitPct = metrics ? Math.round(metrics.cacheHitRate * 100) : 0;

return (
<section className="border border-gray-200 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-700">Usage Metrics</h2>
<div className="flex gap-1">
{PERIODS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setPeriod(value)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
period === value
? 'bg-[#005271] text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{label}
</button>
))}
</div>
</div>

{/* Metric cards */}
<div
className={`grid grid-cols-2 md:grid-cols-4 divide-x divide-y md:divide-y-0 divide-gray-200 transition-opacity ${
loading ? 'opacity-40' : 'opacity-100'
}`}
>
{/* Requests */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 mb-1">Requests</p>
<p className="text-2xl font-bold text-gray-800">
{metrics ? fmt(metrics.totalRequests) : '—'}
</p>
</div>

{/* Cache hit rate */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 mb-1">Cache hit rate</p>
<p className="text-2xl font-bold text-[#005271]">
{metrics ? `${hitPct}%` : '—'}
</p>
{metrics && metrics.totalPromptTokens > 0 && (
<div className="mt-2 h-1.5 rounded-full bg-gray-200 overflow-hidden">
<div
className="h-full rounded-full bg-[#005271] transition-all"
style={{ width: `${hitPct}%` }}
/>
</div>
)}
</div>

{/* Prompt tokens breakdown */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 mb-1">Prompt tokens</p>
{metrics ? (
<>
<p className="text-2xl font-bold text-gray-800">
{fmt(metrics.totalPromptTokens)}
</p>
<div className="mt-1.5 flex flex-col gap-0.5 text-xs text-gray-500">
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-sm bg-[#9EE7E5]" />
{fmt(metrics.totalCachedTokens)} cached
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-sm bg-gray-300" />
{fmt(uncachedTokens)} uncached
</span>
</div>
</>
) : (
<p className="text-2xl font-bold text-gray-800">—</p>
)}
</div>

{/* Completion tokens */}
<div className="px-5 py-4">
<p className="text-xs text-gray-500 mb-1">Completion tokens</p>
<p className="text-2xl font-bold text-gray-800">
{metrics ? fmt(metrics.totalCompletionTokens) : '—'}
</p>
</div>
</div>
</section>
);
}
111 changes: 111 additions & 0 deletions app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client';

import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge';

export default function KnowledgeBanners() {
const {
banner,
setBanner,
reseedResult,
setReseedResult,
clearResult,
setClearResult,
importResult,
setImportResult,
importError,
} = useHackbotKnowledge();

return (
<>
{/* Global banner */}
{banner && (
<div
className={`flex items-center justify-between rounded-lg px-4 py-3 text-sm border ${
banner.kind === 'success'
? 'bg-green-50 border-green-200 text-green-700'
: 'bg-red-50 border-red-200 text-red-700'
}`}
>
<span>{banner.message}</span>
<button
onClick={() => setBanner(null)}
className="text-xs underline opacity-70 hover:opacity-100 ml-4"
>
Dismiss
</button>
</div>
)}

{/* Reseed result */}
{reseedResult && (
<div
className={`flex items-center justify-between rounded-lg px-4 py-3 text-sm border ${
reseedResult.ok
? 'bg-green-50 border-green-200 text-green-700'
: 'bg-red-50 border-red-200 text-red-700'
}`}
>
<span>
{reseedResult.ok
? `Reseeded ${reseedResult.successCount} doc${
reseedResult.successCount !== 1 ? 's' : ''
} successfully.`
: reseedResult.error ??
`${reseedResult.failureCount} doc(s) failed to reseed.`}
</span>
<button
onClick={() => setReseedResult(null)}
className="text-xs underline opacity-70 hover:opacity-100 ml-4"
>
Dismiss
</button>
</div>
)}

{/* Clear result — error only (success goes via banner) */}
{clearResult && !clearResult.ok && (
<div className="flex items-center justify-between bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700">
<span>Failed to clear: {clearResult.error}</span>
<button
onClick={() => setClearResult(null)}
className="text-xs underline opacity-70 hover:opacity-100 ml-4"
>
Dismiss
</button>
</div>
)}

{/* Import result — error only (success goes via banner) */}
{importResult && !importResult.ok && (
<div className="flex flex-col gap-1 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700">
<div className="flex items-center justify-between">
<span>
{importResult.successCount} imported, {importResult.failureCount}{' '}
failed.
</span>
<button
onClick={() => setImportResult(null)}
className="text-xs underline opacity-70 hover:opacity-100 ml-4"
>
Dismiss
</button>
</div>
{importResult.failures.length > 0 && (
<ul className="mt-1 text-xs list-disc list-inside space-y-0.5">
{importResult.failures.map((f, i) => (
<li key={i}>{f}</li>
))}
</ul>
)}
</div>
)}

{/* Import parse error */}
{importError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{importError}
</p>
)}
</>
);
}
133 changes: 133 additions & 0 deletions app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client';

import { RxCross1 } from 'react-icons/rx';
import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge';
import { DOC_TYPES, TYPE_LABELS } from '../../_constants/hackbotKnowledge';
import type { HackDocType } from '@typeDefs/hackbot';

export default function KnowledgeDocModal() {
const {
modalOpen,
editingDoc,
form,
setForm,
formError,
isSaving,
closeModal,
handleSave,
} = useHackbotKnowledge();

if (!modalOpen) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl flex flex-col gap-5 p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">
{editingDoc ? 'Edit Document' : 'Add Document'}
</h2>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close modal"
>
<RxCross1 className="w-4 h-4" />
</button>
</div>

<div className="flex flex-col gap-4">
{/* Type */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Type</label>
<select
value={form.type}
onChange={(e) =>
setForm((f) => ({ ...f, type: e.target.value as HackDocType }))
}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#005271]"
>
{DOC_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</div>

{/* Title */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
placeholder="e.g. Judging Process Overview"
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#005271]"
/>
</div>

{/* URL */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">
URL <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="text"
value={form.url ?? ''}
onChange={(e) =>
setForm((f) => ({ ...f, url: e.target.value || null }))
}
placeholder="e.g. /project-info#judging"
className="border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#005271]"
/>
</div>

{/* Content */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Content</label>
<textarea
value={form.content}
onChange={(e) =>
setForm((f) => ({ ...f, content: e.target.value }))
}
placeholder="Write the knowledge content here. This text is used for both display and semantic search."
rows={8}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[#005271]"
/>
</div>

{formError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{formError}
</p>
)}
</div>

<div className="flex items-center justify-end gap-3 pt-1">
<button
onClick={closeModal}
className="text-sm text-gray-500 hover:text-gray-700 px-4 py-2"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="bg-[#005271] text-white font-semibold px-6 py-2.5 rounded-lg hover:bg-[#003d54] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{isSaving ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Saving…
</span>
) : (
'Save & Embed'
)}
</button>
</div>
</div>
</div>
);
}
Loading
Loading