style(admin-web): format dashboard sources
Some checks failed
Publish @bytelyst/* packages / publish (push) Failing after 13s
CI — Common Platform / Build, Test & Typecheck (push) Successful in 53s

This commit is contained in:
Saravana Kumar 2026-05-30 21:18:09 +00:00
parent 20e1ac0e67
commit 7465b21d91
29 changed files with 422 additions and 308 deletions

View File

@ -37,10 +37,10 @@ curl -X POST "http://localhost:3001/api/seed?secret=<SEED_SECRET>"
### Default Logins
| Email | Password | Role |
| ------------------ | ----------- | ----------- |
| `admin@example.com` | `Admin123!` | Super Admin |
| `viewer@example.com` | `viewer123` | Viewer |
| Email | Password | Role |
| -------------------- | ----------- | ----------- |
| `admin@example.com` | `Admin123!` | Super Admin |
| `viewer@example.com` | `viewer123` | Viewer |
## Environment Variables

View File

@ -52,13 +52,15 @@ describe('GET /api/settings/kill-switch', () => {
it('returns existing kill_switch flag state', async () => {
mockListFlags.mockResolvedValue({
flags: [{
key: 'kill_switch',
enabled: true,
platforms: ['desktop', 'ios'],
description: 'Maintenance window',
updatedAt: '2026-02-16T00:00:00Z',
}],
flags: [
{
key: 'kill_switch',
enabled: true,
platforms: ['desktop', 'ios'],
description: 'Maintenance window',
updatedAt: '2026-02-16T00:00:00Z',
},
],
});
const res = await makeGet();

View File

@ -138,7 +138,7 @@ export default function ExtractionPage() {
}
}, [inputText, selectedTask]);
const currentTask = tasks.find((t) => t.id === selectedTask);
const currentTask = tasks.find(t => t.id === selectedTask);
// Group extractions by class
const groupedExtractions = result?.extractions.reduce(
@ -148,7 +148,7 @@ export default function ExtractionPage() {
acc[cls].push(e);
return acc;
},
{} as Record<string, ExtractionEntity[]>,
{} as Record<string, ExtractionEntity[]>
);
return (
@ -189,7 +189,7 @@ export default function ExtractionPage() {
className="w-full min-h-[200px] rounded-lg border border-border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Paste a transcript, meeting notes, or any text to extract structured entities from..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onChange={e => setInputText(e.target.value)}
/>
<div className="flex items-center gap-2">
<Button onClick={handleExtract} disabled={loading || !inputText.trim()}>
@ -212,10 +212,10 @@ export default function ExtractionPage() {
<select
className="w-full rounded-lg border border-border bg-background p-2 text-sm"
value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)}
onChange={e => setSelectedTask(e.target.value)}
>
{tasks.length > 0 ? (
tasks.map((t) => (
tasks.map(t => (
<option key={t.id} value={t.id}>
{t.name} {t.builtIn ? '(built-in)' : ''}
</option>
@ -249,7 +249,7 @@ export default function ExtractionPage() {
<CardContent className="pt-4 space-y-2 text-xs">
<p className="text-muted-foreground">{currentTask.description}</p>
<div className="flex flex-wrap gap-1">
{currentTask.classes.map((cls) => (
{currentTask.classes.map(cls => (
<Badge
key={cls}
variant="outline"
@ -365,11 +365,7 @@ export default function ExtractionPage() {
{e.attributes && Object.keys(e.attributes).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(e.attributes).map(([k, v]) => (
<Badge
key={k}
variant="secondary"
className="text-[10px]"
>
<Badge key={k} variant="secondary" className="text-[10px]">
{k}: {v}
</Badge>
))}
@ -400,7 +396,10 @@ export default function ExtractionPage() {
{result.extractions.map((e, i) => (
<TableRow key={i}>
<TableCell>
<Badge variant="outline" className={`text-xs ${getClassColor(e.extraction_class)}`}>
<Badge
variant="outline"
className={`text-xs ${getClassColor(e.extraction_class)}`}
>
{e.extraction_class}
</Badge>
</TableCell>

View File

@ -1,14 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import {
Flag,
Plus,
Loader2,
Trash2,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import { Flag, Plus, Loader2, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -83,7 +76,10 @@ export default function FlagsPage() {
enabled: form.enabled,
percentage: form.percentage,
platforms: form.platforms
? form.platforms.split(',').map(s => s.trim()).filter(Boolean)
? form.platforms
.split(',')
.map(s => s.trim())
.filter(Boolean)
: [],
}),
});
@ -251,9 +247,7 @@ export default function FlagsPage() {
<div>
<CardTitle className="text-sm font-mono">{flag.key}</CardTitle>
{flag.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{flag.description}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{flag.description}</p>
)}
</div>
</div>
@ -284,19 +278,9 @@ export default function FlagsPage() {
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{flag.platforms.length > 0 && (
<span>
Platforms: {flag.platforms.join(', ')}
</span>
)}
{flag.segments.length > 0 && (
<span>
Segments: {flag.segments.join(', ')}
</span>
)}
<span>
Updated {new Date(flag.updatedAt).toLocaleDateString()}
</span>
{flag.platforms.length > 0 && <span>Platforms: {flag.platforms.join(', ')}</span>}
{flag.segments.length > 0 && <span>Segments: {flag.segments.join(', ')}</span>}
<span>Updated {new Date(flag.updatedAt).toLocaleDateString()}</span>
</div>
{flag.enabled && (
<div className="mt-3 flex items-center gap-3">

View File

@ -1,17 +1,7 @@
'use client';
import { useState } from 'react';
import {
Key,
Search,
Plus,
Copy,
Check,
Loader2,
Monitor,
Smartphone,
X,
} from 'lucide-react';
import { Key, Search, Plus, Copy, Check, Loader2, Monitor, Smartphone, X } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -131,9 +121,7 @@ export default function LicensesPage() {
body: JSON.stringify({ key, action: 'revoke' }),
});
if (res.ok) {
setLicenses(prev =>
prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l))
);
setLicenses(prev => prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l)));
}
} catch {
// ignore
@ -220,11 +208,7 @@ export default function LicensesPage() {
<code className="text-sm font-mono font-bold text-emerald-800 dark:text-emerald-200">
{generatedKey}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(generatedKey)}
>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(generatedKey)}>
{copiedKey === generatedKey ? (
<Check className="h-4 w-4 text-emerald-600" />
) : (
@ -371,9 +355,7 @@ export default function LicensesPage() {
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No devices activated yet
</p>
<p className="text-xs text-muted-foreground italic">No devices activated yet</p>
)}
</CardContent>
</Card>

View File

@ -1,16 +1,7 @@
'use client';
import { useState } from 'react';
import {
Bell,
Search,
Loader2,
Monitor,
Smartphone,
Tablet,
Check,
X,
} from 'lucide-react';
import { Bell, Search, Loader2, Monitor, Smartphone, Tablet, Check, X } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -86,7 +77,9 @@ export default function NotificationsPage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Look Up User</CardTitle>
<CardDescription>Search by user ID to view their devices and notification preferences</CardDescription>
<CardDescription>
Search by user ID to view their devices and notification preferences
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
@ -122,11 +115,17 @@ export default function NotificationsPage() {
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Push:</span>
{prefs.pushEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
<Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
>
<Check className="mr-1 h-3 w-3" /> Enabled
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<X className="mr-1 h-3 w-3" /> Disabled
</Badge>
)}
@ -134,11 +133,17 @@ export default function NotificationsPage() {
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Email:</span>
{prefs.emailEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
<Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
>
<Check className="mr-1 h-3 w-3" /> Enabled
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<X className="mr-1 h-3 w-3" /> Disabled
</Badge>
)}
@ -167,9 +172,7 @@ export default function NotificationsPage() {
{/* Devices */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Registered Devices ({devices.length})
</CardTitle>
<CardTitle className="text-base">Registered Devices ({devices.length})</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
@ -205,11 +208,17 @@ export default function NotificationsPage() {
</div>
</div>
{device.pushToken ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]">
<Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]"
>
Push token
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]">
<Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]"
>
No push token
</Badge>
)}

View File

@ -221,9 +221,7 @@ export default function SecretsPage() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Secrets Manager</h2>
{vaultUrl && (
<p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>
)}
{vaultUrl && <p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchSecrets} disabled={loading}>
@ -423,7 +421,11 @@ export default function SecretsPage() {
className="h-7 w-7"
onClick={() => setShowValue(!showValue)}
>
{showValue ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{showValue ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
{copied ? (
@ -545,8 +547,8 @@ export default function SecretsPage() {
<DialogTitle className="text-red-500">Delete Secret</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete
the secret in Azure Key Vault. It can be recovered within the retention period.
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete the
secret in Azure Key Vault. It can be recovered within the retention period.
</DialogDescription>
</DialogHeader>
<DialogFooter>

View File

@ -84,7 +84,11 @@ export default function TelemetryPoliciesPage() {
const [formPercentage, setFormPercentage] = useState(100);
const [formStartsAt, setFormStartsAt] = useState('');
const [formExpiresAt, setFormExpiresAt] = useState('');
const [preview, setPreview] = useState<{ matchedClients: number; totalClients: number; sampleSize: number } | null>(null);
const [preview, setPreview] = useState<{
matchedClients: number;
totalClients: number;
sampleSize: number;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const fetchPolicies = useCallback(async () => {
@ -154,7 +158,12 @@ export default function TelemetryPoliciesPage() {
enabled: formEnabled,
priority: formPriority,
eventTypes: formEventTypes,
modules: formModules ? formModules.split(',').map(m => m.trim()).filter(Boolean) : [],
modules: formModules
? formModules
.split(',')
.map(m => m.trim())
.filter(Boolean)
: [],
samplingRate: formSamplingRate,
targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
@ -213,11 +222,7 @@ export default function TelemetryPoliciesPage() {
}
};
const toggleArrayItem = (
arr: string[],
item: string,
setter: (v: string[]) => void
) => {
const toggleArrayItem = (arr: string[], item: string, setter: (v: string[]) => void) => {
setter(arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]);
};
@ -471,12 +476,15 @@ export default function TelemetryPoliciesPage() {
targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
channels: formChannels.length > 0 ? formChannels : undefined,
releaseChannels: formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
releaseChannels:
formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
},
}),
});
if (res.ok) setPreview(await res.json());
} catch { /* best effort */ } finally {
} catch {
/* best effort */
} finally {
setPreviewLoading(false);
}
}}
@ -488,7 +496,8 @@ export default function TelemetryPoliciesPage() {
<span className="text-sm">
<strong className="text-primary">{preview.matchedClients}</strong>
<span className="text-muted-foreground">
{' '}/ {preview.totalClients} clients would match
{' '}
/ {preview.totalClients} clients would match
</span>
<span className="text-xs text-muted-foreground ml-1">
(from {preview.sampleSize} recent events)
@ -581,9 +590,7 @@ export default function TelemetryPoliciesPage() {
<div>
<p className="font-medium">{policy.name}</p>
{policy.description && (
<p className="text-xs text-muted-foreground">
{policy.description}
</p>
<p className="text-xs text-muted-foreground">{policy.description}</p>
)}
</div>
</TableCell>
@ -625,13 +632,9 @@ export default function TelemetryPoliciesPage() {
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{policy.startsAt
? new Date(policy.startsAt).toLocaleDateString()
: '—'}
{policy.startsAt ? new Date(policy.startsAt).toLocaleDateString() : '—'}
{' → '}
{policy.expiresAt
? new Date(policy.expiresAt).toLocaleDateString()
: '∞'}
{policy.expiresAt ? new Date(policy.expiresAt).toLocaleDateString() : '∞'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
@ -647,18 +650,10 @@ export default function TelemetryPoliciesPage() {
<ToggleLeft className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEditForm(policy)}
>
<Button variant="ghost" size="sm" onClick={() => openEditForm(policy)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(policy.id)}
>
<Button variant="ghost" size="sm" onClick={() => handleDelete(policy.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>

View File

@ -124,9 +124,16 @@ export default function ProductsPage() {
const product = await res.json();
setCreateOpen(false);
setForm({
productId: '', displayName: '', licensePrefix: '', packageName: '',
defaultPlan: 'free', trialDays: '14', websiteUrl: '',
deviceLimitFree: '1', deviceLimitPro: '3', deviceLimitEnterprise: '10',
productId: '',
displayName: '',
licensePrefix: '',
packageName: '',
defaultPlan: 'free',
trialDays: '14',
websiteUrl: '',
deviceLimitFree: '1',
deviceLimitPro: '3',
deviceLimitEnterprise: '10',
});
// Auto-onboard: seed plans + kill_switch flag
await handleOnboard(product.productId);
@ -230,9 +237,7 @@ export default function ProductsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
Manage registered products in the platform
</p>
<p className="text-muted-foreground">Manage registered products in the platform</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
@ -271,7 +276,9 @@ export default function ProductsPage() {
<Input
placeholder="PROD"
value={form.licensePrefix}
onChange={e => setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })}
onChange={e =>
setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })
}
/>
</div>
<div className="space-y-2">
@ -290,7 +297,9 @@ export default function ProductsPage() {
value={form.defaultPlan}
onValueChange={v => setForm({ ...form, defaultPlan: v as 'free' | 'pro' })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
@ -322,7 +331,8 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input
type="number" min={0}
type="number"
min={0}
value={form.deviceLimitFree}
onChange={e => setForm({ ...form, deviceLimitFree: e.target.value })}
/>
@ -330,7 +340,8 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input
type="number" min={0}
type="number"
min={0}
value={form.deviceLimitPro}
onChange={e => setForm({ ...form, deviceLimitPro: e.target.value })}
/>
@ -338,7 +349,8 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input
type="number" min={0}
type="number"
min={0}
value={form.deviceLimitEnterprise}
onChange={e => setForm({ ...form, deviceLimitEnterprise: e.target.value })}
/>
@ -367,7 +379,8 @@ export default function ProductsPage() {
Product &quot;{onboardResult.productId}&quot; onboarded successfully
</p>
<p className="text-xs text-emerald-700 dark:text-emerald-300">
{onboardResult.plans} plans seeded{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
{onboardResult.plans} plans seeded
{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
</p>
</div>
</div>
@ -403,7 +416,11 @@ export default function ProductsPage() {
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
}
>
{p.status === 'active' ? <Check className="mr-1 h-3 w-3" /> : <X className="mr-1 h-3 w-3" />}
{p.status === 'active' ? (
<Check className="mr-1 h-3 w-3" />
) : (
<X className="mr-1 h-3 w-3" />
)}
{p.status}
</Badge>
<Button
@ -444,7 +461,8 @@ export default function ProductsPage() {
<div>{p.trialDays}</div>
<div className="text-muted-foreground">Device Limits</div>
<div>
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent: {p.deviceLimits.enterprise}
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent:{' '}
{p.deviceLimits.enterprise}
</div>
{p.websiteUrl && (
<>
@ -488,7 +506,9 @@ export default function ProductsPage() {
value={editForm.status ?? 'active'}
onValueChange={v => setEditForm({ ...editForm, status: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
@ -503,7 +523,9 @@ export default function ProductsPage() {
value={editForm.defaultPlan ?? 'free'}
onValueChange={v => setEditForm({ ...editForm, defaultPlan: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
@ -513,7 +535,9 @@ export default function ProductsPage() {
<div className="space-y-2">
<Label>Trial Days</Label>
<Input
type="number" min={0} max={365}
type="number"
min={0}
max={365}
value={editForm.trialDays ?? '14'}
onChange={e => setEditForm({ ...editForm, trialDays: e.target.value })}
/>
@ -532,7 +556,8 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input
type="number" min={0}
type="number"
min={0}
value={editForm.deviceLimitFree ?? '1'}
onChange={e => setEditForm({ ...editForm, deviceLimitFree: e.target.value })}
/>
@ -540,7 +565,8 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input
type="number" min={0}
type="number"
min={0}
value={editForm.deviceLimitPro ?? '3'}
onChange={e => setEditForm({ ...editForm, deviceLimitPro: e.target.value })}
/>
@ -548,9 +574,12 @@ export default function ProductsPage() {
<div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input
type="number" min={0}
type="number"
min={0}
value={editForm.deviceLimitEnterprise ?? '10'}
onChange={e => setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })}
onChange={e =>
setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })
}
/>
</div>
</div>

View File

@ -30,7 +30,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiListPromos, apiCreatePromo, apiDeletePromo, apiUpdatePromo, type ApiPromo } from '@/lib/api';
import {
apiListPromos,
apiCreatePromo,
apiDeletePromo,
apiUpdatePromo,
type ApiPromo,
} from '@/lib/api';
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
@ -69,7 +75,8 @@ export default function PromosPage() {
const handleToggleActive = async (promo: ApiPromo) => {
const { data } = await apiUpdatePromo(promo.id, { active: !promo.active });
if (data) setPromos(prev => prev.map(p => p.id === promo.id ? { ...p, active: !p.active } : p));
if (data)
setPromos(prev => prev.map(p => (p.id === promo.id ? { ...p, active: !p.active } : p)));
};
const loadPromos = useCallback(async () => {

View File

@ -435,10 +435,13 @@ export default function SettingsPage() {
<CardTitle className="text-base">Azure Configuration</CardTitle>
<CardDescription>
Azure secrets are managed via the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2 hover:text-primary/80">
<a
href="/ops/secrets"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
Secrets Manager
</a>
{' '}(Key Vault)
</a>{' '}
(Key Vault)
</CardDescription>
</div>
</div>
@ -449,8 +452,8 @@ export default function SettingsPage() {
in Azure Key Vault and resolved at runtime. Use the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2">
Secrets Manager
</a>
{' '}to view, rotate, or update them.
</a>{' '}
to view, rotate, or update them.
</p>
</CardContent>
</Card>
@ -473,7 +476,12 @@ export default function SettingsPage() {
<Input
type="number"
value={settings.rateLimits.globalPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 } }))}
onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 },
}))
}
/>
</div>
<div className="space-y-2">
@ -481,7 +489,12 @@ export default function SettingsPage() {
<Input
type="number"
value={settings.rateLimits.perUserPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 } }))}
onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 },
}))
}
/>
</div>
</div>
@ -491,7 +504,12 @@ export default function SettingsPage() {
<Input
type="number"
value={settings.rateLimits.maxTokenBurst}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 } }))}
onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 },
}))
}
/>
</div>
<div className="space-y-2">
@ -499,7 +517,12 @@ export default function SettingsPage() {
<Input
type="number"
value={settings.rateLimits.abuseThreshold}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 } }))}
onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 },
}))
}
/>
</div>
</div>
@ -513,7 +536,9 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.rateLimits.autoSuspendOnAbuse}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))}
onCheckedChange={v =>
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))
}
/>
</div>
<div className="flex items-center justify-between">
@ -525,7 +550,9 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.rateLimits.ipBlocklist}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))}
onCheckedChange={v =>
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))
}
/>
</div>
</CardContent>
@ -552,7 +579,12 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.notifications.newUserSignup}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, newUserSignup: v } }))}
onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, newUserSignup: v },
}))
}
/>
</div>
<div className="flex items-center justify-between">
@ -564,7 +596,12 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.notifications.usageThreshold}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, usageThreshold: v } }))}
onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, usageThreshold: v },
}))
}
/>
</div>
<div className="flex items-center justify-between">
@ -576,7 +613,12 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.notifications.failedPayment}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, failedPayment: v } }))}
onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, failedPayment: v },
}))
}
/>
</div>
<div className="flex items-center justify-between">
@ -588,7 +630,12 @@ export default function SettingsPage() {
</div>
<Switch
checked={settings.notifications.securityAlerts}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, securityAlerts: v } }))}
onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, securityAlerts: v },
}))
}
/>
</div>
</CardContent>
@ -620,7 +667,9 @@ export default function SettingsPage() {
<Input
type="number"
value={settings.dataRetentionDays}
onChange={e => setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))}
onChange={e =>
setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))
}
/>
</div>
<div className="space-y-2">

View File

@ -71,10 +71,7 @@ export default function SubscriptionsPage() {
useEffect(() => {
async function load() {
try {
const [plansRes, usersRes] = await Promise.allSettled([
apiListPlans(),
apiListUsers(),
]);
const [plansRes, usersRes] = await Promise.allSettled([apiListPlans(), apiListUsers()]);
let loadedPlans: LocalPlan[] = [];
if (plansRes.status === 'fulfilled' && plansRes.value.data?.plans?.length) {
loadedPlans = plansRes.value.data.plans.filter(p => p.active).map(planDocToLocal);
@ -238,7 +235,9 @@ export default function SubscriptionsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}</div>
<div className="text-2xl font-bold">
{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}
</div>
<p className="text-xs text-muted-foreground mt-1">ARPU</p>
</CardContent>
</Card>

View File

@ -50,7 +50,13 @@ import {
SelectValue,
} from '@/components/ui/select';
import { mockUsers, formatNumber, formatCurrency, formatDate, type User } from '@/lib/mock-data';
import { apiListUsers, apiUpdateUser, apiDeleteUser, apiCreateInvitation, type ApiUser } from '@/lib/api';
import {
apiListUsers,
apiUpdateUser,
apiDeleteUser,
apiCreateInvitation,
type ApiUser,
} from '@/lib/api';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/toast';
@ -127,10 +133,15 @@ export default function UsersPage() {
});
setInviteCreating(false);
if (data) {
const userDashboardUrl = process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
const userDashboardUrl =
process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
setInviteLink(`${userDashboardUrl}/login?ref=${encodeURIComponent(data.code)}`);
} else {
toast({ title: 'Failed to create invite', description: error || 'Unknown error', variant: 'error' });
toast({
title: 'Failed to create invite',
description: error || 'Unknown error',
variant: 'error',
});
}
};
@ -160,7 +171,10 @@ export default function UsersPage() {
setUsers(prev =>
prev.map(u => (u.id === user.id ? { ...u, status: newStatus as User['status'] } : u))
);
toast({ title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`, variant: newStatus === 'suspended' ? 'warning' : 'success' });
toast({
title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`,
variant: newStatus === 'suspended' ? 'warning' : 'success',
});
} else {
toast({ title: 'Action failed', description: error, variant: 'error' });
}
@ -232,13 +246,15 @@ export default function UsersPage() {
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users and their subscriptions</p>
</div>
<Button onClick={() => {
setShowInvite(true);
setInviteLink(null);
setInviteDescription('');
setInvitePlan('pro');
setInviteCopied(false);
}}>
<Button
onClick={() => {
setShowInvite(true);
setInviteLink(null);
setInviteDescription('');
setInvitePlan('pro');
setInviteCopied(false);
}}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite User
</Button>
@ -434,9 +450,24 @@ export default function UsersPage() {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleChangePlan(user.id, user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free')}
onClick={() =>
handleChangePlan(
user.id,
user.plan === 'free'
? 'pro'
: user.plan === 'pro'
? 'enterprise'
: 'free'
)
}
>
Cycle Plan ({user.plan} {user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free'})
Cycle Plan ({user.plan} {' '}
{user.plan === 'free'
? 'pro'
: user.plan === 'pro'
? 'enterprise'
: 'free'}
)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@ -578,12 +609,7 @@ export default function UsersPage() {
<code className="flex-1 text-sm font-mono break-all select-all">
{inviteLink}
</code>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyInviteLink}
>
<Button variant="ghost" size="icon" className="shrink-0" onClick={copyInviteLink}>
<Copy className="h-4 w-4" />
</Button>
</div>

View File

@ -13,10 +13,7 @@ function getSecretClient(): SecretClient {
}
/** GET /api/ops/secrets/[name] — read a specific secret value */
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
export async function GET(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
try {
const { name } = await params;
const client = getSecretClient();
@ -41,10 +38,7 @@ export async function GET(
}
/** DELETE /api/ops/secrets/[name] — soft-delete a secret */
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
try {
const { name } = await params;
const client = getSecretClient();
@ -55,7 +49,7 @@ export async function DELETE(
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -49,7 +49,7 @@ export async function GET() {
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
{ status: 500 }
);
}
}
@ -67,10 +67,7 @@ export async function POST(req: NextRequest) {
};
if (!name || !value) {
return NextResponse.json(
{ error: 'name and value are required' },
{ status: 400 },
);
return NextResponse.json({ error: 'name and value are required' }, { status: 400 });
}
const client = getSecretClient();
@ -89,7 +86,7 @@ export async function POST(req: NextRequest) {
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -34,7 +34,10 @@ function parseInfoValue(info: string, key: string): string | undefined {
return line?.split(':').slice(1).join(':');
}
async function getPreview(client: ReturnType<typeof createClient>, key: string): Promise<ValkeyPreview> {
async function getPreview(
client: ReturnType<typeof createClient>,
key: string
): Promise<ValkeyPreview> {
const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]);
if (type === 'string') {
@ -76,7 +79,10 @@ async function getPreview(client: ReturnType<typeof createClient>, key: string):
}
if (type === 'zset') {
const [size, entries] = await Promise.all([client.zCard(key), client.zRangeWithScores(key, 0, 4)]);
const [size, entries] = await Promise.all([
client.zCard(key),
client.zRangeWithScores(key, 0, 4),
]);
return {
key,
type,
@ -142,7 +148,10 @@ export async function GET(req: NextRequest) {
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unable to inspect Valkey';
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 });
return NextResponse.json(
{ error: message },
{ status: message === 'Unauthorized' ? 401 : 500 }
);
}
}
@ -215,6 +224,9 @@ export async function POST(req: NextRequest) {
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Valkey write failed';
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 });
return NextResponse.json(
{ error: message },
{ status: message === 'Unauthorized' ? 401 : 500 }
);
}
}

View File

@ -76,7 +76,10 @@ export async function PUT(req: NextRequest) {
const reason = typeof body.reason === 'string' ? body.reason : '';
const platforms: PlatformFlags = body.platforms ?? {
desktop: true, ios: true, android: true, web: true,
desktop: true,
ios: true,
android: true,
web: true,
};
const result = await listFlags();

View File

@ -8,10 +8,7 @@ function getJwt(req: NextRequest): string {
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import {
updateTelemetryPolicy,
deleteTelemetryPolicy,
} from '@/lib/platform-client';
import { updateTelemetryPolicy, deleteTelemetryPolicy } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
@ -11,10 +8,7 @@ function getJwt(req: NextRequest): string {
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@ -28,10 +22,7 @@ export async function PUT(
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listTelemetryPolicies,
createTelemetryPolicy,
} from '@/lib/platform-client';
import { listTelemetryPolicies, createTelemetryPolicy } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';

View File

@ -1,58 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: "bg-background text-foreground",
default: 'bg-background text-foreground',
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };

View File

@ -1,13 +1,12 @@
'use client'
'use client';
import * as React from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface CheckboxProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
export interface CheckboxProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
@ -28,7 +27,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
{checked && <Check className="h-4 w-4" />}
</button>
)
)
Checkbox.displayName = 'Checkbox'
);
Checkbox.displayName = 'Checkbox';
export { Checkbox }
export { Checkbox };

View File

@ -11,10 +11,7 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">

View File

@ -597,7 +597,9 @@ export async function apiGetBroadcast(id: string) {
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`);
}
export async function apiCreateBroadcast(body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
export async function apiCreateBroadcast(
body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
) {
return apiFetch<ApiBroadcast>('/admin/broadcasts', {
method: 'POST',
body: JSON.stringify(body),
@ -661,7 +663,16 @@ export interface ApiSurvey {
description?: string;
questions: {
id: string;
type: 'single_choice' | 'multiple_choice' | 'rating' | 'nps' | 'text_short' | 'text_long' | 'dropdown' | 'scale' | 'ranking';
type:
| 'single_choice'
| 'multiple_choice'
| 'rating'
| 'nps'
| 'text_short'
| 'text_long'
| 'dropdown'
| 'scale'
| 'ranking';
text: string;
description?: string;
required: boolean;
@ -676,7 +687,11 @@ export interface ApiSurvey {
status: 'draft' | 'active' | 'paused' | 'closed';
startsAt?: string;
endsAt?: string;
displayTrigger: { type: 'immediate' } | { type: 'delay_seconds'; seconds: number } | { type: 'event'; eventName: string } | { type: 'page_view'; pagePattern: string };
displayTrigger:
| { type: 'immediate' }
| { type: 'delay_seconds'; seconds: number }
| { type: 'event'; eventName: string }
| { type: 'page_view'; pagePattern: string };
incentive?: { type: 'pro_days' | 'credits'; amount: number };
metrics: {
impressions: number;
@ -733,7 +748,9 @@ export async function apiGetSurvey(id: string) {
return apiFetch<ApiSurvey>(`/admin/surveys/${id}`);
}
export async function apiCreateSurvey(body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
export async function apiCreateSurvey(
body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
) {
return apiFetch<ApiSurvey>('/admin/surveys', {
method: 'POST',
body: JSON.stringify(body),
@ -759,12 +776,17 @@ export async function apiPauseSurvey(id: string) {
return apiFetch<{ success: boolean }>(`/admin/surveys/${id}/pause`, { method: 'POST' });
}
export async function apiGetSurveyResponses(id: string, options?: { isComplete?: boolean; limit?: number; offset?: number }) {
export async function apiGetSurveyResponses(
id: string,
options?: { isComplete?: boolean; limit?: number; offset?: number }
) {
const params = new URLSearchParams();
if (options?.isComplete !== undefined) params.set('isComplete', String(options.isComplete));
if (options?.limit) params.set('limit', String(options.limit));
if (options?.offset) params.set('offset', String(options.offset));
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(`/admin/surveys/${id}/responses?${params}`);
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(
`/admin/surveys/${id}/responses?${params}`
);
}
export async function apiGetSurveyRespondents(id: string) {

View File

@ -58,7 +58,9 @@ export async function updateSubscription(
// ── Usage ───────────────────────────────────────────────────────
export async function listUsage(options: { userId?: string; days?: number; limit?: number; productId?: string } = {}) {
export async function listUsage(
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
) {
const params = new URLSearchParams();
if (options.userId) params.set('userId', options.userId);
if (options.days) params.set('days', String(options.days));

View File

@ -180,7 +180,9 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
if (options.limit) params.set('limit', options.limit.toString());
if (options.offset) params.set('offset', options.offset.toString());
const result = await client.safeFetch<QuerySessionsResult>(`/api/diagnostics/sessions?${params.toString()}`);
const result = await client.safeFetch<QuerySessionsResult>(
`/api/diagnostics/sessions?${params.toString()}`
);
if (result.error) throw new Error(result.error);
return result.data!;
},
@ -202,11 +204,14 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
},
async updateSession(sessionId: string, request: UpdateSessionRequest): Promise<DebugSession> {
const result = await client.safeFetch<DebugSession>(`/api/diagnostics/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await client.safeFetch<DebugSession>(
`/api/diagnostics/sessions/${sessionId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
if (result.error) throw new Error(result.error);
return result.data!;
},
@ -267,7 +272,10 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
// Screenshots
// -------------------------------------------------------------------------
async getScreenshots(sessionId: string, productId: string): Promise<
async getScreenshots(
sessionId: string,
productId: string
): Promise<
Array<{
id: string;
blobUrl: string;

View File

@ -54,7 +54,10 @@ export async function restartServiceContainer(serviceId: string): Promise<{ cont
throw new Error('Service is not restartable from admin ops');
}
const response = await dockerRequest('POST', `/containers/${encodeURIComponent(container)}/restart?t=10`);
const response = await dockerRequest(
'POST',
`/containers/${encodeURIComponent(container)}/restart?t=10`
);
if (![204, 304].includes(response.statusCode)) {
throw new Error(response.body || `Docker restart failed with status ${response.statusCode}`);
}

View File

@ -48,7 +48,7 @@ export interface ExtractionTask {
export async function extractText(
text: string,
taskId?: string,
modelId?: string,
modelId?: string
): Promise<ExtractResponse | null> {
try {
return await extractionApi.fetch<ExtractResponse>('/extract', {
@ -66,7 +66,7 @@ export async function extractTranscript(text: string): Promise<ExtractResponse |
export async function extractBatch(
inputs: Array<{ text: string; taskId?: string }>,
modelId?: string,
modelId?: string
): Promise<ExtractResponse[] | null> {
try {
const result = await extractionApi.fetch<{ results: ExtractResponse[] }>('/extract/batch', {
@ -102,7 +102,9 @@ export async function getTask(id: string): Promise<ExtractionTask | null> {
export async function getSidecarHealth(): Promise<{ status: string; sidecar?: unknown } | null> {
try {
return await extractionApi.fetch<{ status: string; sidecar?: unknown }>('/extract/sidecar-health');
return await extractionApi.fetch<{ status: string; sidecar?: unknown }>(
'/extract/sidecar-health'
);
} catch {
return null;
}

View File

@ -60,7 +60,9 @@ export async function getProductHealthDetail(productId: string): Promise<Product
}
export async function getHealthTrends(productId: string, days = 30): Promise<ProductHealth[]> {
return predictiveApi.fetch<ProductHealth[]>(`/predictive/health/${productId}/trends?days=${days}`);
return predictiveApi.fetch<ProductHealth[]>(
`/predictive/health/${productId}/trends?days=${days}`
);
}
// ── Churn Prediction ─────────────────────────────────────────
@ -89,7 +91,11 @@ export interface ChurnPrediction {
};
}
export async function getChurnScore(userId: string, productId: string, horizon = 30): Promise<ChurnPrediction> {
export async function getChurnScore(
userId: string,
productId: string,
horizon = 30
): Promise<ChurnPrediction> {
return predictiveApi.fetch<ChurnPrediction>('/predictive/churn-score', {
method: 'POST',
body: JSON.stringify({ userId, productId, horizon: String(horizon) }),
@ -106,18 +112,22 @@ export interface AtRiskUser {
predictionTimestamp: string;
}
export async function getAtRiskUsers(options: {
productId?: string;
segment?: RiskSegment;
limit?: number;
offset?: number;
} = {}): Promise<{ users: AtRiskUser[]; total: number }> {
export async function getAtRiskUsers(
options: {
productId?: string;
segment?: RiskSegment;
limit?: number;
offset?: number;
} = {}
): Promise<{ users: AtRiskUser[]; total: number }> {
const params = new URLSearchParams();
if (options.productId) params.set('productId', options.productId);
if (options.segment) params.set('segment', options.segment);
if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset));
return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(`/predictive/at-risk-users?${params}`);
return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(
`/predictive/at-risk-users?${params}`
);
}
export interface UserRiskProfile extends ChurnPrediction {
@ -149,8 +159,12 @@ export async function getModelPerformance(): Promise<ModelPerformance> {
return predictiveApi.fetch<ModelPerformance>('/predictive/model/performance');
}
export async function getFeatureImportance(): Promise<Array<{ feature: string; importance: number }>> {
const res = await predictiveApi.fetch<{ features: Array<{ feature: string; importance: number }> }>('/predictive/model/features');
export async function getFeatureImportance(): Promise<
Array<{ feature: string; importance: number }>
> {
const res = await predictiveApi.fetch<{
features: Array<{ feature: string; importance: number }>;
}>('/predictive/model/features');
return res.features;
}
@ -242,7 +256,10 @@ export async function getCampaignStats(id: string): Promise<Campaign['stats']> {
return predictiveApi.fetch<Campaign['stats']>(`/predictive/campaigns/${id}/stats`);
}
export async function triggerCampaign(id: string, testUserId?: string): Promise<{ triggered: number }> {
export async function triggerCampaign(
id: string,
testUserId?: string
): Promise<{ triggered: number }> {
return predictiveApi.fetch<{ triggered: number }>(`/predictive/campaigns/${id}/trigger`, {
method: 'POST',
body: JSON.stringify(testUserId ? { testUserId } : {}),