feat: Add automation and billing pages with workflow management, invoice generation, and payment tracking

- Add "use client" directive to automation and billing pages for client-side rendering
- Add useState/useEffect hooks for Automation/AutomationRun/Invoice/Payment state management with loading/error/actionLoading states
- Add automation page: loadData with Promise.all for api.automation.list/logs, handleCreateAutomation with JSON config parsing, handleRunAutomation/handleScheduleAutomation with api
This commit is contained in:
nutcas3
2026-04-19 05:25:41 +03:00
parent 84ca116e27
commit 961259cd39
8 changed files with 1947 additions and 73 deletions
@@ -0,0 +1,429 @@
"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 { Bot, Play, Clock, CheckCircle, XCircle, AlertCircle, History, Plus, Calendar } from "lucide-react";
import { api, Automation, AutomationRun } from "@/lib/api";
export default function AutomationPage() {
const [automations, setAutomations] = useState<Automation[]>([]);
const [runs, setRuns] = useState<AutomationRun[]>([]);
const [selectedAutomation, setSelectedAutomation] = useState<Automation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
name: "",
description: "",
trigger: "",
schedule: "",
config: "{}"
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
const [automationsResponse, runsResponse] = await Promise.all([
api.automation.list(),
api.automation.logs()
]);
setAutomations(automationsResponse.automations);
setRuns(runsResponse.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load automation data");
} finally {
setLoading(false);
}
};
const handleCreateAutomation = async () => {
try {
setActionLoading('create');
const config = createForm.config ? JSON.parse(createForm.config) : {};
await api.automation.create({
name: createForm.name,
description: createForm.description,
trigger: createForm.trigger,
schedule: createForm.schedule,
config,
enabled: true
});
// Reset form
setCreateForm({ name: "", description: "", trigger: "", schedule: "", config: "{}" });
setShowCreateForm(false);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create automation");
} finally {
setActionLoading(null);
}
};
const handleRunAutomation = async (automationId: number) => {
try {
setActionLoading(`run-${automationId}`);
await api.automation.run(automationId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to run automation");
} finally {
setActionLoading(null);
}
};
const handleScheduleAutomation = async (automationId: number, schedule: string) => {
try {
setActionLoading(`schedule-${automationId}`);
await api.automation.schedule(automationId, schedule);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to schedule automation");
} finally {
setActionLoading(null);
}
};
const loadAutomationRuns = async (automationId: number) => {
try {
const runsResponse = await api.automation.logs(automationId);
setRuns(runsResponse.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load automation runs");
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle className="size-4 text-green-500" />;
case 'failed': return <XCircle className="size-4 text-red-500" />;
case 'running': return <Play className="size-4 text-blue-500" />;
case 'pending': return <Clock className="size-4 text-yellow-500" />;
default: return <AlertCircle className="size-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'success';
case 'failed': return 'destructive';
case 'running': return 'default';
case 'pending': return 'secondary';
default: return 'secondary';
}
};
const formatDuration = (started: string, completed?: string) => {
const start = new Date(started);
const end = completed ? new Date(completed) : new Date();
const duration = end.getTime() - start.getTime();
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${minutes}m ${seconds}s`;
};
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 lg:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-64 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 automation data</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">
<div className="flex items-center gap-3">
<Bot className="size-8 text-primary" />
<div>
<h1 className="text-3xl font-bold tracking-tight">Automation</h1>
<p className="text-muted-foreground mt-1">Workflow automation and scheduling.</p>
</div>
</div>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="size-4 mr-2" />
Create Automation
</Button>
</div>
{/* Create Automation Form */}
{showCreateForm && (
<Card>
<CardHeader>
<CardTitle>Create New Automation</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Name</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder="e.g., Daily Backup"
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">Description</label>
<input
type="text"
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
placeholder="e.g., Backup database daily at midnight"
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">Trigger</label>
<input
type="text"
value={createForm.trigger}
onChange={(e) => setCreateForm({ ...createForm, trigger: e.target.value })}
placeholder="e.g., manual, schedule, webhook"
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">Schedule (cron)</label>
<input
type="text"
value={createForm.schedule}
onChange={(e) => setCreateForm({ ...createForm, schedule: e.target.value })}
placeholder="e.g., 0 0 * * * (daily at midnight)"
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">Configuration (JSON)</label>
<textarea
value={createForm.config}
onChange={(e) => setCreateForm({ ...createForm, config: e.target.value })}
placeholder='{"backup_path": "/backups", "retention": "7d"}'
className="mt-1 block w-full h-20 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleCreateAutomation} disabled={actionLoading !== null || !createForm.name || !createForm.trigger}>
{actionLoading === 'create' ? 'Creating...' : 'Create Automation'}
</Button>
<Button variant="outline" onClick={() => setShowCreateForm(false)}>
Cancel
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Automation Statistics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Bot className="size-5 text-blue-500" />
<div>
<p className="text-2xl font-bold">{automations.length}</p>
<p className="text-sm text-muted-foreground">Total Automations</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="size-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{automations.filter(a => a.enabled).length}</p>
<p className="text-sm text-muted-foreground">Enabled</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Play className="size-5 text-purple-500" />
<div>
<p className="text-2xl font-bold">{runs.filter(r => r.status === 'running').length}</p>
<p className="text-sm text-muted-foreground">Running</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<History className="size-5 text-orange-500" />
<div>
<p className="text-2xl font-bold">{runs.length}</p>
<p className="text-sm text-muted-foreground">Total Runs</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Automations List */}
<Card>
<CardHeader>
<CardTitle>Automations</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{automations.map((automation) => (
<div key={automation.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="font-medium">{automation.name}</h3>
<p className="text-sm text-muted-foreground">{automation.description}</p>
</div>
<Badge variant={automation.enabled ? "success" : "secondary"}>
{automation.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-3">
<p>Trigger: {automation.trigger}</p>
{automation.schedule && <p>Schedule: {automation.schedule}</p>}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleRunAutomation(automation.id)}
disabled={actionLoading !== null}
>
{actionLoading === `run-${automation.id}` ? 'Running...' : (
<>
<Play className="size-3 mr-1" />
Run Now
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedAutomation(automation)}
>
<History className="size-3 mr-1" />
View Runs
</Button>
{automation.trigger === 'schedule' && (
<Button
size="sm"
variant="outline"
onClick={() => {
const newSchedule = prompt("Enter new schedule (cron):", automation.schedule || "");
if (newSchedule) handleScheduleAutomation(automation.id, newSchedule);
}}
disabled={actionLoading !== null}
>
{actionLoading === `schedule-${automation.id}` ? 'Updating...' : (
<>
<Calendar className="size-3 mr-1" />
Schedule
</>
)}
</Button>
)}
</div>
</div>
))}
{automations.length === 0 && (
<p className="text-muted-foreground text-center py-8">No automations found</p>
)}
</div>
</CardContent>
</Card>
{/* Runs History */}
<Card>
<CardHeader>
<CardTitle>
{selectedAutomation ? `${selectedAutomation.name} - Runs` : 'Recent Runs'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{runs.map((run) => (
<div key={run.id} className="border rounded p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{getStatusIcon(run.status)}
<span className="font-medium text-sm">Run #{run.id}</span>
</div>
<Badge variant={getStatusColor(run.status)}>
{run.status}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
<p>Started: {new Date(run.started_at).toLocaleString()}</p>
<p>Duration: {formatDuration(run.started_at, run.completed_at)}</p>
{run.completed_at && (
<p>Completed: {new Date(run.completed_at).toLocaleString()}</p>
)}
</div>
{run.output && (
<div className="mt-2">
<p className="text-sm font-medium mb-1">Output:</p>
<div className="bg-gray-100 p-2 rounded text-xs max-h-20 overflow-auto">
<pre>{run.output}</pre>
</div>
</div>
)}
{run.error && (
<div className="mt-2">
<p className="text-sm font-medium mb-1 text-red-600">Error:</p>
<div className="bg-red-50 p-2 rounded text-xs text-red-700 max-h-20 overflow-auto">
<pre>{run.error}</pre>
</div>
</div>
)}
</div>
))}
{runs.length === 0 && (
<p className="text-muted-foreground text-center py-8">No runs found</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
"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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
DollarSign,
FileText,
CreditCard,
Calendar,
Download,
Eye,
Plus,
Search,
Filter
} from "lucide-react";
import { api, Invoice, Payment } from "@/lib/api";
export default function BillingPage() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"invoices" | "payments">("invoices");
// Filters
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// Generate invoice form
const [showGenerateForm, setShowGenerateForm] = useState(false);
const [generateForm, setGenerateForm] = useState({
subscriberId: "",
month: "",
year: new Date().getFullYear().toString()
});
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadData();
}, [activeTab, statusFilter, page]);
const loadData = async () => {
try {
setLoading(true);
if (activeTab === "invoices") {
const invoicesResponse = await api.billing.invoices(undefined, statusFilter, page, pageSize);
setInvoices(invoicesResponse.data);
} else {
const paymentsResponse = await api.billing.payments(undefined, statusFilter, page, pageSize);
setPayments(paymentsResponse.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load billing data");
} finally {
setLoading(false);
}
};
const handleGenerateInvoice = async () => {
try {
setActionLoading('generate');
await api.billing.generateInvoice(
parseInt(generateForm.subscriberId),
generateForm.month,
parseInt(generateForm.year)
);
// Reset form and reload data
setGenerateForm({ subscriberId: "", month: "", year: new Date().getFullYear().toString() });
setShowGenerateForm(false);
loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate invoice");
} finally {
setActionLoading(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed":
case "paid":
return "bg-green-100 text-green-800";
case "pending":
return "bg-yellow-100 text-yellow-800";
case "failed":
return "bg-red-100 text-red-800";
case "refunded":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD'
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const filteredInvoices = invoices.filter(invoice =>
invoice.id.toString().includes(searchTerm) ||
invoice.status.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredPayments = payments.filter(payment =>
payment.transaction_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.status.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Billing & Invoicing</h1>
<div className="flex gap-2">
<Button
onClick={() => setShowGenerateForm(true)}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Generate Invoice
</Button>
</div>
</div>
{/* Generate Invoice Modal */}
{showGenerateForm && (
<Card className="p-6">
<CardHeader>
<CardTitle>Generate New Invoice</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="subscriberId">Subscriber ID</Label>
<Input
id="subscriberId"
type="number"
value={generateForm.subscriberId}
onChange={(e) => setGenerateForm({...generateForm, subscriberId: e.target.value})}
placeholder="Enter subscriber ID"
/>
</div>
<div>
<Label htmlFor="month">Month</Label>
<Select value={generateForm.month} onValueChange={(value) => setGenerateForm({...generateForm, month: value})}>
<SelectTrigger>
<SelectValue placeholder="Select month" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">January</SelectItem>
<SelectItem value="2">February</SelectItem>
<SelectItem value="3">March</SelectItem>
<SelectItem value="4">April</SelectItem>
<SelectItem value="5">May</SelectItem>
<SelectItem value="6">June</SelectItem>
<SelectItem value="7">July</SelectItem>
<SelectItem value="8">August</SelectItem>
<SelectItem value="9">September</SelectItem>
<SelectItem value="10">October</SelectItem>
<SelectItem value="11">November</SelectItem>
<SelectItem value="12">December</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="year">Year</Label>
<Input
id="year"
type="number"
value={generateForm.year}
onChange={(e) => setGenerateForm({...generateForm, year: e.target.value})}
placeholder="Enter year"
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleGenerateInvoice}
disabled={actionLoading === 'generate'}
className="flex items-center gap-2"
>
<FileText className="h-4 w-4" />
{actionLoading === 'generate' ? 'Generating...' : 'Generate Invoice'}
</Button>
<Button
variant="outline"
onClick={() => setShowGenerateForm(false)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Tabs */}
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setActiveTab("invoices")}
className={`flex-1 py-2 px-4 rounded-md transition-colors ${
activeTab === "invoices"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<FileText className="h-4 w-4 inline mr-2" />
Invoices
</button>
<button
onClick={() => setActiveTab("payments")}
className={`flex-1 py-2 px-4 rounded-md transition-colors ${
activeTab === "payments"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<CreditCard className="h-4 w-4 inline mr-2" />
Payments
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-48">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Statuses</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="refunded">Refunded</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<p className="text-red-800">{error}</p>
</CardContent>
</Card>
)}
{loading ? (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">Loading billing data...</div>
</CardContent>
</Card>
) : (
<>
{activeTab === "invoices" ? (
<div className="space-y-4">
{filteredInvoices.map((invoice) => (
<Card key={invoice.id}>
<CardContent className="p-6">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold">Invoice #{invoice.id}</h3>
<Badge className={getStatusColor(invoice.status)}>
{invoice.status}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
<div>
<Calendar className="h-4 w-4 inline mr-1" />
Due: {formatDate(invoice.due_date)}
</div>
<div>
<DollarSign className="h-4 w-4 inline mr-1" />
Amount: {formatCurrency(invoice.amount, invoice.currency)}
</div>
<div>
Created: {formatDate(invoice.created_at)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
View
</Button>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-1" />
Download
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{filteredInvoices.length === 0 && (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No invoices found</p>
</div>
</CardContent>
</Card>
)}
</div>
) : (
<div className="space-y-4">
{filteredPayments.map((payment) => (
<Card key={payment.id}>
<CardContent className="p-6">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-2">
<CreditCard className="h-5 w-5 text-green-600" />
<h3 className="font-semibold">Payment #{payment.id}</h3>
<Badge className={getStatusColor(payment.status)}>
{payment.status}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
<div>
<DollarSign className="h-4 w-4 inline mr-1" />
Amount: {formatCurrency(payment.amount, payment.currency)}
</div>
<div>
Method: {payment.method}
</div>
<div>
<Calendar className="h-4 w-4 inline mr-1" />
{formatDate(payment.created_at)}
</div>
</div>
<div className="mt-2 text-sm text-gray-500">
Transaction ID: {payment.transaction_id}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
View
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{filteredPayments.length === 0 && (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">
<CreditCard className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No payments found</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
</>
)}
</div>
);
}
+394
View File
@@ -0,0 +1,394 @@
"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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Settings,
Save,
RefreshCw,
Check,
X,
AlertTriangle,
Search,
Plus,
Edit,
Trash2,
Eye,
EyeOff
} from "lucide-react";
import { api, ConfigEntry, ConfigValidationResult } from "@/lib/api";
export default function ConfigPage() {
const [configs, setConfigs] = useState<ConfigEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ConfigEntry | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [validationResult, setValidationResult] = useState<ConfigValidationResult | null>(null);
const [showValidation, setShowValidation] = useState(false);
// Form state
const [formData, setFormData] = useState({
key: "",
value: "",
type: "string",
description: "",
sensitive: false
});
useEffect(() => {
loadConfigs();
}, []);
const loadConfigs = async () => {
try {
setLoading(true);
const configsResponse = await api.config.get();
setConfigs(Array.isArray(configsResponse) ? configsResponse : [configsResponse]);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load configuration");
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setActionLoading('save');
if (editingConfig) {
// Update existing config
await api.config.update(editingConfig.key, formData.value, formData.type);
} else {
// Create new config
await api.config.update(formData.key, formData.value, formData.type);
}
// Reset form and reload data
setFormData({ key: "", value: "", type: "string", description: "", sensitive: false });
setEditingConfig(null);
setShowAddForm(false);
loadConfigs();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setActionLoading(null);
}
};
const handleDelete = async (key: string) => {
try {
setActionLoading('delete');
await api.config.delete(key);
loadConfigs();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete configuration");
} finally {
setActionLoading(null);
}
};
const handleValidate = async () => {
try {
setActionLoading('validate');
const configObject = configs.reduce((acc, config) => {
acc[config.key] = config.type === 'boolean' ? config.value === 'true' :
config.type === 'number' ? parseFloat(config.value) :
config.value;
return acc;
}, {} as Record<string, any>);
const result = await api.config.validate(configObject);
setValidationResult(result);
setShowValidation(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to validate configuration");
} finally {
setActionLoading(null);
}
};
const startEdit = (config: ConfigEntry) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
type: config.type,
description: config.description,
sensitive: config.sensitive
});
setShowAddForm(true);
};
const cancelEdit = () => {
setEditingConfig(null);
setFormData({ key: "", value: "", type: "string", description: "", sensitive: false });
setShowAddForm(false);
};
const getTypeColor = (type: string) => {
switch (type) {
case "string":
return "bg-blue-100 text-blue-800";
case "number":
return "bg-green-100 text-green-800";
case "boolean":
return "bg-purple-100 text-purple-800";
case "json":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatValue = (value: string, type: string, sensitive: boolean) => {
if (sensitive) {
return "********";
}
if (type === "boolean") {
return value === "true" ? "True" : "False";
}
if (type === "json") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
return value;
};
const filteredConfigs = configs.filter(config =>
config.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.description.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Configuration Management</h1>
<div className="flex gap-2">
<Button
onClick={handleValidate}
disabled={actionLoading === 'validate'}
variant="outline"
className="flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
{actionLoading === 'validate' ? 'Validating...' : 'Validate Config'}
</Button>
<Button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Config
</Button>
</div>
</div>
{/* Validation Result */}
{showValidation && validationResult && (
<Card className={validationResult.valid ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}>
<CardContent className="p-4">
<div className="flex items-center gap-2">
{validationResult.valid ? (
<Check className="h-5 w-5 text-green-600" />
) : (
<X className="h-5 w-5 text-red-600" />
)}
<span className={validationResult.valid ? "text-green-800" : "text-red-800"}>
{validationResult.valid ? "Configuration is valid" : "Configuration has errors"}
</span>
</div>
{!validationResult.valid && validationResult.errors.length > 0 && (
<ul className="mt-2 text-red-700 text-sm">
{validationResult.errors.map((error, index) => (
<li key={index}>- {error}</li>
))}
</ul>
)}
</CardContent>
</Card>
)}
{/* Add/Edit Config Form */}
{showAddForm && (
<Card className="p-6">
<CardHeader>
<CardTitle>{editingConfig ? "Edit Configuration" : "Add New Configuration"}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="key">Configuration Key</Label>
<Input
id="key"
value={formData.key}
onChange={(e) => setFormData({...formData, key: e.target.value})}
placeholder="e.g., database.max_connections"
disabled={!!editingConfig}
/>
</div>
<div>
<Label htmlFor="value">Value</Label>
{formData.type === "json" ? (
<Textarea
id="value"
value={formData.value}
onChange={(e) => setFormData({...formData, value: e.target.value})}
placeholder="JSON value"
rows={4}
/>
) : (
<Input
id="value"
value={formData.value}
onChange={(e) => setFormData({...formData, value: e.target.value})}
placeholder="Configuration value"
type={formData.sensitive ? "password" : "text"}
/>
)}
</div>
<div>
<Label htmlFor="type">Type</Label>
<Select value={formData.type} onValueChange={(value) => setFormData({...formData, type: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
placeholder="Configuration description"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="sensitive"
checked={formData.sensitive}
onCheckedChange={(checked) => setFormData({...formData, sensitive: checked})}
/>
<Label htmlFor="sensitive">Sensitive (will be masked)</Label>
</div>
<div className="flex gap-2">
<Button
onClick={handleSave}
disabled={actionLoading === 'save' || !formData.key || !formData.value}
className="flex items-center gap-2"
>
<Save className="h-4 w-4" />
{actionLoading === 'save' ? 'Saving...' : 'Save Config'}
</Button>
<Button variant="outline" onClick={cancelEdit}>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="Search configurations..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{error && (
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4">
<p className="text-red-800">{error}</p>
</CardContent>
</Card>
)}
{loading ? (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">Loading configuration...</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredConfigs.map((config) => (
<Card key={config.key}>
<CardContent className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Settings className="h-5 w-5 text-gray-600" />
<h3 className="font-semibold">{config.key}</h3>
<Badge className={getTypeColor(config.type)}>
{config.type}
</Badge>
{config.sensitive && (
<Badge variant="outline">
<EyeOff className="h-3 w-3 mr-1" />
Sensitive
</Badge>
)}
</div>
<p className="text-gray-600 text-sm mb-2">{config.description}</p>
<div className="bg-gray-50 p-3 rounded-md">
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
{formatValue(config.value, config.type, config.sensitive)}
</pre>
</div>
<div className="text-xs text-gray-500 mt-2">
Last updated: {new Date(config.updated_at).toLocaleString()}
</div>
</div>
<div className="flex gap-2 ml-4">
<Button variant="outline" size="sm" onClick={() => startEdit(config)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(config.key)}
disabled={actionLoading === 'delete'}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{filteredConfigs.length === 0 && (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">
<Settings className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No configurations found</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
"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 { Rocket, RotateCcw, Play, History, Clock, CheckCircle, XCircle, AlertCircle } from "lucide-react";
import { api, Deployment } from "@/lib/api";
export default function DeployPage() {
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showDeployForm, setShowDeployForm] = useState(false);
const [deployForm, setDeployForm] = useState({
service: "",
version: "",
metadata: "{}"
});
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadDeployments();
}, []);
const loadDeployments = async () => {
try {
setLoading(true);
const deploymentsResponse = await api.deployments.status();
setDeployments(deploymentsResponse.deployments);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load deployments");
} finally {
setLoading(false);
}
};
const handleStartDeployment = async () => {
try {
setActionLoading('deploy');
const metadata = deployForm.metadata ? JSON.parse(deployForm.metadata) : {};
await api.deployments.start({
service: deployForm.service,
version: deployForm.version,
metadata
});
// Reset form
setDeployForm({ service: "", version: "", metadata: "{}" });
setShowDeployForm(false);
await loadDeployments();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start deployment");
} finally {
setActionLoading(null);
}
};
const handleRollback = async (deploymentId: number) => {
try {
setActionLoading(`rollback-${deploymentId}`);
await api.deployments.rollback(deploymentId);
await loadDeployments();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rollback deployment");
} finally {
setActionLoading(null);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle className="size-4 text-green-500" />;
case 'failed': return <XCircle className="size-4 text-red-500" />;
case 'running': return <Play className="size-4 text-blue-500" />;
case 'pending': return <Clock className="size-4 text-yellow-500" />;
case 'rolling_back': return <RotateCcw className="size-4 text-orange-500" />;
default: return <AlertCircle className="size-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'success';
case 'failed': return 'destructive';
case 'running': return 'default';
case 'pending': return 'secondary';
case 'rolling_back': return 'warning';
default: return 'secondary';
}
};
const formatDuration = (started: string, completed?: string) => {
const start = new Date(started);
const end = completed ? new Date(completed) : new Date();
const duration = end.getTime() - start.getTime();
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${minutes}m ${seconds}s`;
};
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 deployments</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">
<div className="flex items-center gap-3">
<Rocket className="size-8 text-primary" />
<div>
<h1 className="text-3xl font-bold tracking-tight">Deployments</h1>
<p className="text-muted-foreground mt-1">Deployment pipeline management and history.</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={loadDeployments} disabled={loading}>
<RotateCcw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowDeployForm(true)}>
<Play className="size-4 mr-2" />
New Deployment
</Button>
</div>
</div>
{/* Deployment Form */}
{showDeployForm && (
<Card>
<CardHeader>
<CardTitle>Start New Deployment</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Service Name</label>
<input
type="text"
value={deployForm.service}
onChange={(e) => setDeployForm({ ...deployForm, service: e.target.value })}
placeholder="e.g., api-server"
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">Version</label>
<input
type="text"
value={deployForm.version}
onChange={(e) => setDeployForm({ ...deployForm, version: e.target.value })}
placeholder="e.g., v1.2.3"
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">Metadata (JSON)</label>
<textarea
value={deployForm.metadata}
onChange={(e) => setDeployForm({ ...deployForm, metadata: e.target.value })}
placeholder='{"commit": "abc123", "author": "John Doe"}'
className="mt-1 block w-full h-20 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleStartDeployment} disabled={actionLoading !== null || !deployForm.service || !deployForm.version}>
{actionLoading === 'deploy' ? 'Deploying...' : 'Start Deployment'}
</Button>
<Button variant="outline" onClick={() => setShowDeployForm(false)}>
Cancel
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Deployment Statistics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="size-5 text-green-500" />
<div>
<p className="text-2xl font-bold">
{deployments.filter(d => d.status === 'completed').length}
</p>
<p className="text-sm text-muted-foreground">Completed</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Play className="size-5 text-blue-500" />
<div>
<p className="text-2xl font-bold">
{deployments.filter(d => d.status === 'running').length}
</p>
<p className="text-sm text-muted-foreground">Running</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<XCircle className="size-5 text-red-500" />
<div>
<p className="text-2xl font-bold">
{deployments.filter(d => d.status === 'failed').length}
</p>
<p className="text-sm text-muted-foreground">Failed</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<History className="size-5 text-gray-500" />
<div>
<p className="text-2xl font-bold">{deployments.length}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Deployments List */}
<Card>
<CardHeader>
<CardTitle>Deployment History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{deployments.map((deployment) => (
<div key={deployment.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getStatusIcon(deployment.status)}
<div>
<h3 className="font-medium">{deployment.service}</h3>
<p className="text-sm text-muted-foreground">Version {deployment.version}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusColor(deployment.status)}>
{deployment.status}
</Badge>
<span className="text-sm text-muted-foreground">
ID: {deployment.id}
</span>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Started</p>
<p className="font-medium">{new Date(deployment.started_at).toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">Duration</p>
<p className="font-medium">{formatDuration(deployment.started_at, deployment.completed_at)}</p>
</div>
<div>
<p className="text-muted-foreground">Completed</p>
<p className="font-medium">
{deployment.completed_at ? new Date(deployment.completed_at).toLocaleString() : 'In progress'}
</p>
</div>
<div className="flex items-end">
{deployment.status === 'completed' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRollback(deployment.id)}
disabled={actionLoading !== null}
>
{actionLoading === `rollback-${deployment.id}` ? 'Rolling back...' : 'Rollback'}
</Button>
)}
</div>
</div>
{deployment.metadata && Object.keys(deployment.metadata).length > 0 && (
<div className="mt-3 pt-3 border-t">
<p className="text-sm text-muted-foreground mb-2">Metadata:</p>
<div className="bg-gray-100 p-2 rounded text-xs">
<pre>{JSON.stringify(deployment.metadata, null, 2)}</pre>
</div>
</div>
)}
</div>
))}
{deployments.length === 0 && (
<p className="text-muted-foreground text-center py-8">No deployments found</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+70 -7
View File
@@ -1,20 +1,83 @@
"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 { Radio, Download, RefreshCw } from "lucide-react";
import { api, Subscriber } from "@/lib/api";
const profiles = [
{ id: "prof_001", imsi: "208930000000001", euiccId: "89049032004008882600001", status: "active", subscriber: "Alice Martin", activatedAt: "2026-03-15" },
{ id: "prof_002", imsi: "208930000000002", euiccId: "89049032004008882600002", status: "active", subscriber: "Bob Dupont", activatedAt: "2026-03-20" },
{ id: "prof_003", imsi: "208930000000003", euiccId: "89049032004008882600003", status: "downloading", subscriber: "Claire Moreau", activatedAt: "-" },
{ id: "prof_004", imsi: "208930000000005", euiccId: "89049032004008882600005", status: "active", subscriber: "Emma Petit", activatedAt: "2026-02-10" },
{ id: "prof_005", imsi: "208930000000008", euiccId: "89049032004008882600008", status: "inactive", subscriber: "Henri Faure", activatedAt: "-" },
];
interface ESIMProfile {
id: string;
imsi: string;
euiccId: string;
status: "active" | "downloading" | "inactive" | "failed";
subscriber: string;
activatedAt: string;
}
const statusVariant = (s: string) =>
s === "active" ? "success" : s === "downloading" ? "secondary" : s === "failed" ? "destructive" : "warning";
export default function ESIMPage() {
const [profiles, setProfiles] = useState<ESIMProfile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadProfiles = async () => {
try {
setLoading(true);
const subscribersResponse = await api.subscribers.list(1, 100);
// Map subscribers to eSIM profiles (mock data structure for now)
const esimProfiles: ESIMProfile[] = subscribersResponse.data.map((subscriber) => ({
id: `prof_${subscriber.id.toString().padStart(3, '0')}`,
imsi: subscriber.imsi,
euiccId: `890490320040088826${subscriber.id.toString().padStart(5, '0')}`,
status: subscriber.profile_status === "active" ? "active" :
subscriber.status === "provisioning" ? "downloading" :
"inactive",
subscriber: `${subscriber.first_name} ${subscriber.last_name}`,
activatedAt: subscriber.created_at.split('T')[0]
}));
setProfiles(esimProfiles);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load eSIM profiles");
} finally {
setLoading(false);
}
};
loadProfiles();
}, []);
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 eSIM profiles</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">
+127 -8
View File
@@ -1,17 +1,136 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HeartPulse, Server, Database, Cpu, HardDrive, Wifi } from "lucide-react";
import { api, HealthStatus } from "@/lib/api";
const services = [
{ name: "API Server", icon: Server, status: "healthy", latency: "12ms", uptime: "99.99%", cpu: "23%", memory: "512 MB" },
{ name: "PostgreSQL", icon: Database, status: "healthy", latency: "3ms", uptime: "99.97%", cpu: "15%", memory: "2.1 GB" },
{ name: "Redis Cache", icon: Database, status: "healthy", latency: "1ms", uptime: "99.99%", cpu: "5%", memory: "256 MB" },
{ name: "Charging Engine (Rust)", icon: Cpu, status: "healthy", latency: "2ms", uptime: "99.98%", cpu: "8%", memory: "64 MB" },
{ name: "AMF Gateway", icon: Wifi, status: "healthy", latency: "45ms", uptime: "99.95%", cpu: "12%", memory: "128 MB" },
{ name: "ES2+ SM-DP+", icon: HardDrive, status: "degraded", latency: "230ms", uptime: "98.50%", cpu: "N/A", memory: "N/A" },
];
interface ServiceHealth {
name: string;
icon: any;
status: "healthy" | "degraded" | "unhealthy";
latency: string;
uptime: string;
cpu: string;
memory: string;
}
export default function HealthPage() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [services, setServices] = useState<ServiceHealth[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadHealthData = async () => {
try {
setLoading(true);
const [healthData, monitoringHealth] = await Promise.all([
api.system.health(),
api.monitoring.health()
]);
setHealth(healthData);
// Map monitoring health to service health
const serviceList: ServiceHealth[] = [
{
name: "API Server",
icon: Server,
status: healthData ? "healthy" : "unhealthy",
latency: "12ms",
uptime: "99.99%",
cpu: "23%",
memory: "512 MB"
},
{
name: "PostgreSQL",
icon: Database,
status: "healthy",
latency: "3ms",
uptime: "99.97%",
cpu: "15%",
memory: "2.1 GB"
},
{
name: "Redis Cache",
icon: Database,
status: healthData?.redis_connected ? "healthy" : "unhealthy",
latency: "1ms",
uptime: "99.99%",
cpu: "5%",
memory: "256 MB"
},
{
name: "Charging Engine",
icon: Cpu,
status: healthData?.active_sync ? "healthy" : "degraded",
latency: "2ms",
uptime: "99.98%",
cpu: "8%",
memory: "64 MB"
},
{
name: "AMF Gateway",
icon: Wifi,
status: monitoringHealth?.services?.["amf-gateway"] === "healthy" ? "healthy" : "degraded",
latency: "45ms",
uptime: "99.95%",
cpu: "12%",
memory: "128 MB"
},
{
name: "ES2+ SM-DP+",
icon: HardDrive,
status: monitoringHealth?.services?.["esim-sm-dp"] === "healthy" ? "healthy" : "degraded",
latency: "230ms",
uptime: "98.50%",
cpu: "N/A",
memory: "N/A"
},
];
setServices(serviceList);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load health data");
} finally {
setLoading(false);
}
};
loadHealthData();
}, []);
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 md:grid-cols-2 xl:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-32 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 health data</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">
+100 -37
View File
@@ -1,9 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatCard } from "@/components/stat-card";
import { Users, Activity, AlertTriangle, Server, Wifi, Database } from "lucide-react";
import { api, SystemStats, HealthStatus, Subscriber } from "@/lib/api";
export default function DashboardPage() {
const [stats, setStats] = useState<SystemStats | null>(null);
const [health, setHealth] = useState<HealthStatus | null>(null);
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
const [statsData, healthData, subscribersData] = await Promise.all([
api.system.stats(),
api.system.health(),
api.subscribers.list(1, 5)
]);
setStats(statsData);
setHealth(healthData);
setSubscribers(subscribersData.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load dashboard data");
} finally {
setLoading(false);
}
};
loadData();
}, []);
if (loading) {
return (
<div className="p-8 space-y-8">
<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>
);
}
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 dashboard</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-8">
<div>
@@ -14,30 +76,27 @@ export default function DashboardPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Subscribers"
value="2,847"
value={stats?.total_accounts?.toLocaleString() || "0"}
icon={Users}
trend={{ value: 12.5, positive: true }}
description="from last month"
description="total accounts"
/>
<StatCard
title="Active Sessions"
value="342"
value={stats?.active_sessions?.toLocaleString() || "0"}
icon={Activity}
trend={{ value: 3.2, positive: true }}
description="from last hour"
description="currently active"
/>
<StatCard
title="Low Balance Alerts"
value="18"
value={stats?.low_balance_alerts?.toLocaleString() || "0"}
icon={AlertTriangle}
trend={{ value: -8, positive: true }}
description="from yesterday"
description="need attention"
/>
<StatCard
title="System Uptime"
value="99.97%"
value={`${((stats?.uptime || 0) / (24 * 3600 * 1000)).toFixed(1)} days`}
icon={Server}
description="last 30 days"
description="continuous operation"
/>
</div>
@@ -48,29 +107,28 @@ export default function DashboardPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ name: "Alice Martin", msisdn: "+33612345678", status: "active" as const },
{ name: "Bob Dupont", msisdn: "+33698765432", status: "active" as const },
{ name: "Claire Moreau", msisdn: "+33678901234", status: "provisioning" as const },
{ name: "David Leroy", msisdn: "+33667890123", status: "suspended" as const },
{ name: "Emma Petit", msisdn: "+33656789012", status: "active" as const },
].map((sub) => (
<div key={sub.msisdn} className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{sub.name}</p>
<p className="text-xs text-muted-foreground">{sub.msisdn}</p>
{subscribers.length > 0 ? (
subscribers.map((sub) => (
<div key={sub.id} className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{sub.first_name} {sub.last_name}</p>
<p className="text-xs text-muted-foreground">{sub.msisdn}</p>
</div>
<Badge
variant={
sub.status === "active" ? "success"
: sub.status === "suspended" ? "warning"
: sub.status === "terminated" ? "destructive"
: "secondary"
}
>
{sub.status}
</Badge>
</div>
<Badge
variant={
sub.status === "active" ? "success"
: sub.status === "suspended" ? "warning"
: "secondary"
}
>
{sub.status}
</Badge>
</div>
))}
))
) : (
<p className="text-sm text-muted-foreground">No recent subscribers found</p>
)}
</div>
</CardContent>
</Card>
@@ -82,11 +140,11 @@ export default function DashboardPage() {
<CardContent>
<div className="space-y-4">
{[
{ label: "API Server", icon: Server, status: "Healthy", ok: true },
{ label: "API Server", icon: Server, status: health ? "Connected" : "Disconnected", ok: true },
{ label: "Database", icon: Database, status: "Connected", ok: true },
{ label: "Redis Cache", icon: Database, status: "Connected", ok: true },
{ label: "Charging Engine", icon: Activity, status: "Running", ok: true },
{ label: "AMF Gateway", icon: Wifi, status: "Connected", ok: true },
{ label: "Redis Cache", icon: Database, status: health?.redis_connected ? "Connected" : "Disconnected", ok: health?.redis_connected },
{ label: "Charging Engine", icon: Activity, status: health?.active_sync ? "Running" : "Stopped", ok: health?.active_sync },
{ label: "Memory Usage", icon: Server, status: `${health?.memory_usage || 0}%`, ok: (health?.memory_usage || 0) < 80 },
].map((svc) => (
<div key={svc.label} className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -99,6 +157,11 @@ export default function DashboardPage() {
</div>
</div>
))}
{health?.last_sync && (
<div className="text-xs text-muted-foreground pt-2 border-t">
Last sync: {new Date(health.last_sync).toLocaleString()}
</div>
)}
</div>
</CardContent>
</Card>
+112 -21
View File
@@ -1,18 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatCard } from "@/components/stat-card";
import { CreditCard, ArrowUpRight, ArrowDownRight, Clock } from "lucide-react";
import { api, Payment, PaginatedResponse } from "@/lib/api";
const transactions = [
{ id: "txn_001", subscriber: "Alice Martin", type: "TOP_UP", amount: 50.0, status: "completed", date: "2026-04-14 14:23" },
{ id: "txn_002", subscriber: "Bob Dupont", type: "TOP_UP", amount: 25.0, status: "completed", date: "2026-04-14 13:15" },
{ id: "txn_003", subscriber: "David Leroy", type: "TOP_UP", amount: 10.0, status: "failed", date: "2026-04-14 12:45" },
{ id: "txn_004", subscriber: "Emma Petit", type: "REFUND", amount: -15.0, status: "completed", date: "2026-04-14 11:30" },
{ id: "txn_005", subscriber: "Henri Faure", type: "TOP_UP", amount: 100.0, status: "pending", date: "2026-04-14 10:00" },
{ id: "txn_006", subscriber: "François Blanc", type: "TOP_UP", amount: 30.0, status: "completed", date: "2026-04-13 22:15" },
];
interface PaymentStats {
revenue_today: string;
refunds_today: string;
pending_count: number;
success_rate: string;
}
export default function PaymentsPage() {
const [payments, setPayments] = useState<Payment[]>([]);
const [stats, setStats] = useState<PaymentStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadPaymentData = async () => {
try {
setLoading(true);
const [paymentsResponse] = await Promise.all([
api.billing.payments(undefined, undefined, 1, 50)
]);
setPayments(paymentsResponse.data);
// Calculate stats from payments data
const today = new Date().toISOString().split('T')[0];
const todayPayments = paymentsResponse.data.filter(p =>
p.created_at.startsWith(today)
);
const revenue = todayPayments
.filter(p => p.status === 'completed' && p.amount > 0)
.reduce((sum, p) => sum + p.amount, 0);
const refunds = todayPayments
.filter(p => p.status === 'completed' && p.amount < 0)
.reduce((sum, p) => sum + Math.abs(p.amount), 0);
const pending = todayPayments.filter(p => p.status === 'pending').length;
const completed = paymentsResponse.data.filter(p => p.status === 'completed').length;
const total = paymentsResponse.data.length;
const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : '0';
setStats({
revenue_today: `EUR${revenue.toFixed(2)}`,
refunds_today: `EUR${refunds.toFixed(2)}`,
pending_count: pending,
success_rate: `${successRate}%`
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load payment data");
} finally {
setLoading(false);
}
};
loadPaymentData();
}, []);
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 payment data</h3>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
</div>
);
}
return (
<div className="p-8 space-y-6">
<div>
@@ -21,10 +106,10 @@ export default function PaymentsPage() {
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard title="Revenue Today" value="€1,842" icon={ArrowUpRight} trend={{ value: 15, positive: true }} description="vs yesterday" />
<StatCard title="Refunds Today" value="€15" icon={ArrowDownRight} description="1 refund" />
<StatCard title="Pending" value="1" icon={Clock} description="transaction" />
<StatCard title="Success Rate" value="95.2%" icon={CreditCard} description="last 7 days" />
<StatCard title="Revenue Today" value={stats?.revenue_today || "EUR0"} icon={ArrowUpRight} description="today" />
<StatCard title="Refunds Today" value={stats?.refunds_today || "EUR0"} icon={ArrowDownRight} description="today" />
<StatCard title="Pending" value={stats?.pending_count?.toString() || "0"} icon={Clock} description="transactions" />
<StatCard title="Success Rate" value={stats?.success_rate || "0%"} icon={CreditCard} description="all time" />
</div>
<Card>
@@ -45,22 +130,28 @@ export default function PaymentsPage() {
</tr>
</thead>
<tbody>
{transactions.map((txn) => (
<tr key={txn.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-3 font-mono text-xs">{txn.id}</td>
<td className="py-3">{txn.subscriber}</td>
{payments.map((payment) => (
<tr key={payment.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-3 font-mono text-xs">{payment.transaction_id}</td>
<td className="py-3">Invoice #{payment.invoice_id}</td>
<td className="py-3">
<Badge variant={txn.type === "REFUND" ? "warning" : "secondary"}>{txn.type}</Badge>
<Badge variant="secondary">{payment.method}</Badge>
</td>
<td className="py-3 text-right font-mono">
<span className={txn.amount < 0 ? "text-red-600" : ""}>€{Math.abs(txn.amount).toFixed(2)}</span>
<span className={payment.amount < 0 ? "text-red-600" : ""}>EUR{Math.abs(payment.amount).toFixed(2)}</span>
</td>
<td className="py-3">
<Badge variant={txn.status === "completed" ? "success" : txn.status === "failed" ? "destructive" : "secondary"}>
{txn.status}
<Badge variant={
payment.status === "completed" ? "success" :
payment.status === "failed" ? "destructive" :
"secondary"
}>
{payment.status}
</Badge>
</td>
<td className="py-3 text-right text-muted-foreground text-xs">{txn.date}</td>
<td className="py-3 text-right text-muted-foreground text-xs">
{new Date(payment.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>