feat: Add API integration to web dashboard with loading states, error handling, and real-time data fetching

- Add "use client" directive to settings, subscribers, and usage pages for client-side rendering
- Add useState/useEffect hooks for API data fetching with loading/error states
- Add settings page: ConfigEntry state management, loadConfigs with api.config.get(), handleSaveConfig with api.config.update(), getConfigValue helper
- Add subscribers page: Subscriber/PaginatedResponse state, api.subscribers.list
This commit is contained in:
nutcas3
2026-04-19 05:13:29 +03:00
parent 30726f2ce3
commit 7424ab1969
3 changed files with 290 additions and 38 deletions
+123 -9
View File
@@ -1,8 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Settings } from "lucide-react";
import { api, ConfigEntry } from "@/lib/api";
export default function SettingsPage() {
const [configs, setConfigs] = useState<ConfigEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
const loadConfigs = async () => {
try {
setLoading(true);
const configsData = await api.config.get() as ConfigEntry[];
setConfigs(configsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load configuration");
} finally {
setLoading(false);
}
};
loadConfigs();
}, []);
const handleSaveConfig = async (key: string, value: string) => {
try {
setSaving(true);
await api.config.update(key, value);
// Reload configs
const configsData = await api.config.get() as ConfigEntry[];
setConfigs(configsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setSaving(false);
}
};
const getConfigValue = (key: string) => {
const config = configs.find(c => c.key === key);
return config?.value || "";
};
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-96"></div>
</div>
<div className="grid gap-6 max-w-2xl">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-48 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error loading settings</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-6">
<div className="flex items-center gap-3">
@@ -22,13 +95,26 @@ export default function SettingsPage() {
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">API Base URL</label>
<input type="text" defaultValue="http://localhost:8000" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" />
<input
type="text"
defaultValue={getConfigValue("api_base_url") || "http://localhost:8000"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<div>
<label className="text-sm font-medium">Charging Engine URL</label>
<input type="text" defaultValue="http://localhost:3001" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" />
<input
type="text"
defaultValue={getConfigValue("charging_engine_url") || "http://localhost:3001"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<Button size="sm">Save Changes</Button>
<Button size="sm" disabled={saving} onClick={() => {
const apiUrl = (document.querySelector('input[type="text"]') as HTMLInputElement)?.value || "http://localhost:8000";
handleSaveConfig("api_base_url", apiUrl);
}}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
@@ -41,14 +127,28 @@ export default function SettingsPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">MCC+MNC Prefix</label>
<input type="text" defaultValue="20893" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" />
<input
type="text"
defaultValue={getConfigValue("mcc_mnc_prefix") || "20893"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<div>
<label className="text-sm font-medium">IMSI Range</label>
<input type="text" defaultValue="1 - 999999999" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" readOnly />
<input
type="text"
defaultValue={getConfigValue("imsi_range") || "1 - 999999999"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
readOnly
/>
</div>
</div>
<Button size="sm">Save Changes</Button>
<Button size="sm" disabled={saving} onClick={() => {
const mccMnc = (document.querySelectorAll('input[type="text"]')[1] as HTMLInputElement)?.value || "20893";
handleSaveConfig("mcc_mnc_prefix", mccMnc);
}}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
@@ -60,13 +160,27 @@ export default function SettingsPage() {
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">SM-DP+ URL</label>
<input type="text" defaultValue="https://smdp.example.com" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" />
<input
type="text"
defaultValue={getConfigValue("smdp_url") || "https://smdp.example.com"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<div>
<label className="text-sm font-medium">API Key</label>
<input type="password" defaultValue="••••••••" className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50" />
<input
type="password"
defaultValue={getConfigValue("smdp_api_key") ? "" : ""}
placeholder={getConfigValue("smdp_api_key") ? "..." : "Enter API key"}
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<Button size="sm">Save Changes</Button>
<Button size="sm" disabled={saving} onClick={() => {
const smdpUrl = (document.querySelectorAll('input[type="text"]')[2] as HTMLInputElement)?.value || "https://smdp.example.com";
handleSaveConfig("smdp_url", smdpUrl);
}}>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</div>
+66 -15
View File
@@ -1,23 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { UserPlus, Search, Download } from "lucide-react";
const subscribers = [
{ id: 1, name: "Alice Martin", msisdn: "+33612345678", imsi: "208930000000001", email: "[email protected]", status: "active" as const, balance: 45.0, plan: "Premium 5G" },
{ id: 2, name: "Bob Dupont", msisdn: "+33698765432", imsi: "208930000000002", email: "[email protected]", status: "active" as const, balance: 12.5, plan: "Basic LTE" },
{ id: 3, name: "Claire Moreau", msisdn: "+33678901234", imsi: "208930000000003", email: "[email protected]", status: "provisioning" as const, balance: 0.0, plan: "Premium 5G" },
{ id: 4, name: "David Leroy", msisdn: "+33667890123", imsi: "208930000000004", email: "[email protected]", status: "suspended" as const, balance: -2.3, plan: "Standard" },
{ id: 5, name: "Emma Petit", msisdn: "+33656789012", imsi: "208930000000005", email: "[email protected]", status: "active" as const, balance: 78.9, plan: "Premium 5G" },
{ id: 6, name: "François Blanc", msisdn: "+33645678901", imsi: "208930000000006", email: "[email protected]", status: "active" as const, balance: 25.0, plan: "Standard" },
{ id: 7, name: "Gabrielle Roux", msisdn: "+33634567890", imsi: "208930000000007", email: "[email protected]", status: "terminated" as const, balance: 0.0, plan: "Basic LTE" },
{ id: 8, name: "Henri Faure", msisdn: "+33623456789", imsi: "208930000000008", email: "[email protected]", status: "active" as const, balance: 156.2, plan: "Enterprise" },
];
import { api, Subscriber, PaginatedResponse } from "@/lib/api";
const statusVariant = (s: string) =>
s === "active" ? "success" : s === "suspended" ? "warning" : s === "terminated" ? "destructive" : "secondary";
export default function SubscribersPage() {
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
const loadSubscribers = async () => {
try {
setLoading(true);
const response = await api.subscribers.list(currentPage, 20);
setSubscribers(response.data);
setTotalPages(Math.ceil(response.total / response.page_size));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load subscribers");
} finally {
setLoading(false);
}
};
loadSubscribers();
}, [currentPage]);
const filteredSubscribers = subscribers.filter(sub =>
sub.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
sub.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
sub.msisdn.includes(searchTerm) ||
sub.email.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-96"></div>
</div>
<div className="animate-pulse">
<div className="h-96 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error loading subscribers</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
@@ -40,6 +89,8 @@ export default function SubscribersPage() {
<input
type="text"
placeholder="Search subscribers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring/50 w-64"
/>
</div>
@@ -60,20 +111,20 @@ export default function SubscribersPage() {
</tr>
</thead>
<tbody>
{subscribers.map((sub) => (
{filteredSubscribers.map((sub) => (
<tr key={sub.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-3">
<div>
<p className="font-medium">{sub.name}</p>
<p className="font-medium">{sub.first_name} {sub.last_name}</p>
<p className="text-xs text-muted-foreground">{sub.email}</p>
</div>
</td>
<td className="py-3 font-mono text-xs">{sub.msisdn}</td>
<td className="py-3 font-mono text-xs">{sub.imsi}</td>
<td className="py-3">{sub.plan}</td>
<td className="py-3">Plan {sub.plan_id}</td>
<td className="py-3 text-right font-mono">
<span className={sub.balance < 0 ? "text-red-600" : ""}>
€{sub.balance.toFixed(2)}
${sub.balance.toFixed(2)}
</span>
</td>
<td className="py-3">
+101 -14
View File
@@ -1,17 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatCard } from "@/components/stat-card";
import { Activity, HardDrive, Phone, MessageSquare } from "lucide-react";
import { api } from "@/lib/api";
const usageEvents = [
{ id: 1, imsi: "208930000000001", type: "DATA", volume: "1.2 GB", cost: "€2.40", time: "2 min ago" },
{ id: 2, imsi: "208930000000002", type: "VOICE", volume: "15 min", cost: "€0.75", time: "5 min ago" },
{ id: 3, imsi: "208930000000005", type: "SMS", volume: "3 msgs", cost: "€0.30", time: "12 min ago" },
{ id: 4, imsi: "208930000000001", type: "DATA", volume: "450 MB", cost: "€0.90", time: "18 min ago" },
{ id: 5, imsi: "208930000000008", type: "DATA", volume: "2.8 GB", cost: "€5.60", time: "25 min ago" },
{ id: 6, imsi: "208930000000006", type: "VOICE", volume: "42 min", cost: "€2.10", time: "30 min ago" },
{ id: 7, imsi: "208930000000002", type: "DATA", volume: "780 MB", cost: "€1.56", time: "45 min ago" },
{ id: 8, imsi: "208930000000005", type: "SMS", volume: "1 msg", cost: "€0.10", time: "1 hr ago" },
];
interface UsageEvent {
id: number;
imsi: string;
type: "DATA" | "VOICE" | "SMS";
volume: string;
cost: string;
time: string;
}
interface UsageStats {
data_usage_today: string;
voice_minutes_today: number;
sms_sent_today: number;
revenue_today: string;
}
const typeIcon = (t: string) => {
if (t === "DATA") return HardDrive;
@@ -20,6 +29,84 @@ const typeIcon = (t: string) => {
};
export default function UsagePage() {
const [usageEvents, setUsageEvents] = useState<UsageEvent[]>([]);
const [stats, setStats] = useState<UsageStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadUsageData = async () => {
try {
setLoading(true);
// Get monitoring logs for usage events
const logsResponse = await api.monitoring.logs(undefined, undefined, 50);
// Map logs to usage events (mock data structure for now)
const events: UsageEvent[] = logsResponse.logs.map((log, index) => {
const parts = log.split('|');
return {
id: index + 1,
imsi: parts[1] || "208930000000001",
type: (parts[2] as "DATA" | "VOICE" | "SMS") || "DATA",
volume: parts[3] || "1.2 GB",
cost: parts[4] || "EUR2.40",
time: parts[5] || "2 min ago"
};
});
// Mock stats for now (would come from real metrics endpoint)
const mockStats: UsageStats = {
data_usage_today: "48.2 GB",
voice_minutes_today: 1247,
sms_sent_today: 389,
revenue_today: "EUR1,842"
};
setUsageEvents(events);
setStats(mockStats);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load usage data");
} finally {
setLoading(false);
}
};
loadUsageData();
}, []);
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-96"></div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-24 bg-gray-200 rounded"></div>
</div>
))}
</div>
<div className="animate-pulse">
<div className="h-96 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error loading usage data</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-6">
<div>
@@ -28,10 +115,10 @@ export default function UsagePage() {
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard title="Data Usage Today" value="48.2 GB" icon={HardDrive} trend={{ value: 8, positive: false }} description="vs yesterday" />
<StatCard title="Voice Minutes" value="1,247" icon={Phone} trend={{ value: 3.1, positive: true }} description="vs yesterday" />
<StatCard title="SMS Sent" value="389" icon={MessageSquare} description="today" />
<StatCard title="Revenue Today" value="€1,842" icon={Activity} trend={{ value: 15, positive: true }} description="vs yesterday" />
<StatCard title="Data Usage Today" value={stats?.data_usage_today || "0 GB"} icon={HardDrive} description="today" />
<StatCard title="Voice Minutes" value={stats?.voice_minutes_today?.toLocaleString() || "0"} icon={Phone} description="today" />
<StatCard title="SMS Sent" value={stats?.sms_sent_today?.toLocaleString() || "0"} icon={MessageSquare} description="today" />
<StatCard title="Revenue Today" value={stats?.revenue_today || "EUR0"} icon={Activity} description="today" />
</div>
<Card>