feat: Implement a modern and professional Operational Page

This commit is contained in:
Tola Leng
2025-06-06 18:46:50 +08:00
parent ba47c1d953
commit dd2c64420d
6 changed files with 1235 additions and 0 deletions
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { Badge } from '@/components/ui/badge';
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService';
interface ComponentsSelectorProps {
selectedComponents: Partial<StatusPageComponentRecord>[];
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
onComponentDelete?: (componentId: string) => void;
}
const componentTypes = [
{ value: 'uptime', label: 'Uptime Service', icon: Server },
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
];
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
});
// Fetch uptime services for the dropdown
const { data: services = [] } = useQuery({
queryKey: ['services'],
queryFn: serviceService.getServices,
});
const addComponent = () => {
if (!newComponent.name.trim()) return;
const component: Partial<StatusPageComponentRecord> = {
...newComponent,
operational_status_id: '', // Will be set when page is created
};
onComponentsChange([...selectedComponents, component]);
setNewComponent({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 2,
});
setShowAddForm(false);
};
const removeComponent = async (index: number) => {
const component = selectedComponents[index];
// If component has an ID, it exists in database and needs to be deleted
if (component.id && onComponentDelete) {
await onComponentDelete(component.id);
} else {
// For new components not yet saved, just remove from local state
const updated = selectedComponents.filter((_, i) => i !== index);
onComponentsChange(updated);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Status Page Components
</CardTitle>
<CardDescription>
Add monitoring components like uptime services, SSL certificates, and incident tracking
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedComponents.length > 0 && (
<div className="space-y-2">
<Label>Selected Components</Label>
<div className="space-y-2">
{selectedComponents.map((component, index) => (
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{component.name}</div>
{component.description && (
<div className="text-sm text-muted-foreground">{component.description}</div>
)}
<div className="flex gap-2 mt-1">
{component.service_id && (
<Badge variant="secondary" className="text-xs">
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
</Badge>
)}
{component.server_id && (
<Badge variant="secondary" className="text-xs">
Server: {component.server_id}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeComponent(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{!showAddForm ? (
<Button
variant="outline"
onClick={() => setShowAddForm(true)}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Add Component
</Button>
) : (
<div className="border rounded-lg p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component-name">Component Name</Label>
<Input
id="component-name"
placeholder="e.g., Main Website"
value={newComponent.name}
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
/>
</div>
<div>
<Label htmlFor="display-order">Display Order</Label>
<Input
id="display-order"
type="number"
value={newComponent.display_order}
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="component-description">Description (Optional)</Label>
<Textarea
id="component-description"
placeholder="Brief description of this component"
value={newComponent.description}
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
<SelectTrigger>
<SelectValue placeholder="Select an uptime service" />
</SelectTrigger>
<SelectContent className="z-50 bg-white border shadow-lg">
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status === 'up' ? 'bg-green-500' :
service.status === 'down' ? 'bg-red-500' :
'bg-yellow-500'
}`} />
{service.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="server-id">Server ID (Optional)</Label>
<Input
id="server-id"
placeholder="server_456"
value={newComponent.server_id}
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
Add Component
</Button>
<Button variant="outline" onClick={() => setShowAddForm(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="mt-4">
<Label>Quick Add Templates</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
{componentTypes.map((type) => {
const Icon = type.icon;
return (
<Button
key={type.value}
variant="outline"
size="sm"
onClick={() => {
const component: Partial<StatusPageComponentRecord> = {
name: type.label,
description: `Monitor ${type.label.toLowerCase()}`,
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
operational_status_id: '',
};
onComponentsChange([...selectedComponents, component]);
}}
className="justify-start"
>
<Icon className="h-4 w-4 mr-2" />
{type.label}
</Button>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,280 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector';
import { Plus } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
slug: z.string().min(1, 'Slug is required'),
theme: z.string().min(1, 'Theme is required'),
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
is_public: z.boolean(),
logo_url: z.string().optional(),
custom_domain: z.string().optional(),
custom_css: z.string().optional(),
page_style: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
export const CreateOperationalPageDialog = () => {
const [open, setOpen] = useState(false);
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const createMutation = useCreateOperationalPage();
const createComponentMutation = useCreateStatusPageComponent();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
description: '',
slug: '',
theme: 'default',
status: 'operational',
is_public: true,
logo_url: '',
custom_domain: '',
custom_css: '',
page_style: '',
},
});
const onSubmit = async (data: FormData) => {
try {
const payload = {
title: data.title,
description: data.description,
slug: data.slug,
theme: data.theme,
status: data.status,
is_public: data.is_public ? 'true' : 'false',
logo_url: data.logo_url || '',
custom_domain: data.custom_domain || '',
custom_css: data.custom_css || '',
page_style: data.page_style || '',
};
console.log('Creating operational page with payload:', payload);
const createdPage = await createMutation.mutateAsync(payload);
console.log('Created page:', createdPage);
// Create components after page is created
if (selectedComponents.length > 0) {
console.log('Creating components for page:', createdPage.id);
for (const component of selectedComponents) {
const componentPayload = {
operational_status_id: createdPage.id,
name: component.name || '',
description: component.description || '',
service_id: component.service_id || '',
server_id: component.server_id || '',
display_order: component.display_order || 1,
};
console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload);
}
}
setOpen(false);
form.reset();
setSelectedComponents([]);
} catch (error) {
console.error('Error creating operational page:', error);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Create Page
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Operational Page</DialogTitle>
<DialogDescription>
Create a new operational status page to monitor your services and components.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My Service Status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="my-service-status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="A brief description of your operational page"
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="blue">Blue</SelectItem>
<SelectItem value="green">Green</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Initial Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="operational">Operational</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_public"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Public Page</FormLabel>
<FormDescription>
Make this page publicly accessible
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_domain"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="status.yourdomain.com" {...field} />
</FormControl>
<FormDescription>
Custom domain for your status page
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ComponentsSelector
selectedComponents={selectedComponents}
onComponentsChange={setSelectedComponents}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || createComponentMutation.isPending}
>
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,353 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
slug: z.string().min(1, 'Slug is required'),
theme: z.string().min(1, 'Theme is required'),
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
is_public: z.boolean(),
logo_url: z.string().optional(),
custom_domain: z.string().optional(),
custom_css: z.string().optional(),
page_style: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
interface EditOperationalPageDialogProps {
page: OperationalPageRecord | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const [existingComponents, setExistingComponents] = useState<StatusPageComponentRecord[]>([]);
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const updateMutation = useUpdateOperationalPage();
const createComponentMutation = useCreateStatusPageComponent();
const deleteComponentMutation = useDeleteStatusPageComponent();
// Fetch existing components for this operational page
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
description: '',
slug: '',
theme: 'default',
status: 'operational',
is_public: true,
logo_url: '',
custom_domain: '',
custom_css: '',
page_style: '',
},
});
useEffect(() => {
if (page) {
form.reset({
title: page.title,
description: page.description,
slug: page.slug,
theme: page.theme,
status: page.status,
is_public: page.is_public === 'true',
logo_url: page.logo_url || '',
custom_domain: page.custom_domain || '',
custom_css: page.custom_css || '',
page_style: page.page_style || '',
});
}
}, [page, form]);
useEffect(() => {
if (components && components.length > 0) {
console.log('Loading existing components:', components);
setExistingComponents(components);
// Convert existing components to the format expected by ComponentsSelector
const existingComponentsForSelector = components.map(comp => ({
id: comp.id,
name: comp.name,
description: comp.description,
service_id: comp.service_id,
server_id: comp.server_id,
display_order: comp.display_order,
operational_status_id: comp.operational_status_id,
}));
setSelectedComponents(existingComponentsForSelector);
} else {
setExistingComponents([]);
setSelectedComponents([]);
}
}, [components]);
const handleComponentDelete = async (componentId: string) => {
try {
console.log('Deleting component:', componentId);
await deleteComponentMutation.mutateAsync(componentId);
// Update local state to remove the deleted component
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
setExistingComponents(prev => prev.filter(comp => comp.id !== componentId));
} catch (error) {
console.error('Error deleting component:', error);
}
};
const onSubmit = async (data: FormData) => {
if (!page) return;
try {
setIsFormSubmitting(true);
const payload = {
title: data.title,
description: data.description,
slug: data.slug,
theme: data.theme,
status: data.status,
is_public: data.is_public ? 'true' : 'false',
logo_url: data.logo_url || '',
custom_domain: data.custom_domain || '',
custom_css: data.custom_css || '',
page_style: data.page_style || '',
};
console.log('Updating operational page with payload:', payload);
await updateMutation.mutateAsync({ id: page.id, data: payload });
// Handle component changes
const currentComponentIds = existingComponents.map(c => c.id);
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
const componentsToKeep = selectedComponents.filter(comp => comp.id && currentComponentIds.includes(comp.id));
const componentsToDelete = existingComponents.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
// Delete removed components (only if not already deleted via handleComponentDelete)
for (const component of componentsToDelete) {
if (selectedComponents.some(selected => selected.id === component.id)) {
continue; // Skip if already handled by handleComponentDelete
}
console.log('Deleting component during save:', component.id);
await deleteComponentMutation.mutateAsync(component.id);
}
// Create new components
for (const component of newComponentsToCreate) {
const componentPayload = {
operational_status_id: page.id,
name: component.name || '',
description: component.description || '',
service_id: component.service_id || '',
server_id: component.server_id || '',
display_order: component.display_order || 1,
};
console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload);
}
onOpenChange(false);
} catch (error) {
console.error('Error updating operational page:', error);
} finally {
setIsFormSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Operational Page</DialogTitle>
<DialogDescription>
Update your operational status page settings and manage components.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My Service Status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="my-service-status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="A brief description of your operational page"
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="blue">Blue</SelectItem>
<SelectItem value="green">Green</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="operational">Operational</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_public"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Public Page</FormLabel>
<FormDescription>
Make this page publicly accessible
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_domain"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="status.yourdomain.com" {...field} />
</FormControl>
<FormDescription>
Custom domain for your status page
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ComponentsSelector
selectedComponents={selectedComponents}
onComponentsChange={setSelectedComponents}
onComponentDelete={handleComponentDelete}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
>
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,101 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusBadge } from './StatusBadge';
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
interface OperationalPageCardProps {
page: OperationalPageRecord;
onEdit?: (page: OperationalPageRecord) => void;
onView?: (page: OperationalPageRecord) => void;
onDelete?: (page: OperationalPageRecord) => void;
}
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
return (
<Card className="hover:shadow-lg transition-shadow duration-200">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{page.description}
</CardDescription>
</div>
<StatusBadge status={page.status} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-muted-foreground">Slug:</span>
<p className="mt-1">{page.slug}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Theme:</span>
<p className="mt-1 capitalize">{page.theme}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Public:</span>
<p className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'}
</Badge>
</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Updated:</span>
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
</div>
</div>
{page.custom_domain && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{page.custom_domain}</span>
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
)}
<div className="flex gap-2 pt-2">
{onView && (
<Button
variant="outline"
size="sm"
onClick={() => onView(page)}
className="flex-1"
>
<Eye className="h-4 w-4 mr-2" />
View
</Button>
)}
{onEdit && (
<Button
variant="outline"
size="sm"
onClick={() => onEdit(page)}
className="flex-1"
>
<Settings className="h-4 w-4 mr-2" />
Edit
</Button>
)}
{onDelete && (
<Button
variant="outline"
size="sm"
onClick={() => onDelete(page)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
import { OperationalPageCard } from './OperationalPageCard';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { OperationalPageRecord } from '@/types/operational.types';
import { Activity, Plus, RefreshCw } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export const OperationalPageContent = () => {
const navigate = useNavigate();
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
const deleteMutation = useDeleteOperationalPage();
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
const handleEdit = (page: OperationalPageRecord) => {
setEditingPage(page);
setEditDialogOpen(true);
};
const handleView = (page: OperationalPageRecord) => {
if (page.custom_domain) {
window.open(`https://${page.custom_domain}`, '_blank');
} else {
// Navigate to the public status page route
window.open(`/status/${page.slug}`, '_blank');
}
};
const handleDelete = (page: OperationalPageRecord) => {
setPageToDelete(page);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (pageToDelete) {
try {
await deleteMutation.mutateAsync(pageToDelete.id);
setDeleteDialogOpen(false);
setPageToDelete(null);
} catch (error) {
console.error('Error deleting page:', error);
}
}
};
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div>
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
<p className="text-muted-foreground mb-4">
There was an error loading your operational pages. Please try again.
</p>
<Button onClick={() => refetch()} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
<p className="text-muted-foreground">
Manage your public status pages and monitor service health
</p>
</div>
<div className="flex items-center gap-2 mt-4 sm:mt-0">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<CreateOperationalPageDialog />
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<div className="p-6">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
<div className="flex gap-2 mt-4">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pages || pages.length === 0) && (
<Card className="p-12">
<CardContent className="text-center">
<div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div>
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
<p className="text-muted-foreground mb-6">
Create your first operational page to start monitoring your services and communicate status to your users.
</p>
<CreateOperationalPageDialog />
</CardContent>
</Card>
)}
{/* Pages Grid */}
{!isLoading && pages && pages.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pages.map((page) => (
<OperationalPageCard
key={page.id}
page={page}
onEdit={handleEdit}
onView={handleView}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Stats Footer */}
{!isLoading && pages && pages.length > 0 && (
<div className="mt-8 pt-6 border-t">
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
<div>
<span className="font-medium">Total Pages:</span> {pages.length}
</div>
<div>
<span className="font-medium">Public Pages:</span>{' '}
{pages.filter(p => p.is_public === 'true').length}
</div>
<div>
<span className="font-medium">Operational:</span>{' '}
{pages.filter(p => p.status === 'operational').length}
</div>
</div>
</div>
)}
{/* Edit Dialog */}
<EditOperationalPageDialog
page={editingPage}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone and will permanently remove the operational page and all its components.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -0,0 +1,47 @@
import { Badge } from '@/components/ui/badge';
import { OperationalPageRecord } from '@/types/operational.types';
interface StatusBadgeProps {
status: OperationalPageRecord['status'];
}
export const StatusBadge = ({ status }: StatusBadgeProps) => {
const getStatusConfig = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return {
label: 'Operational',
className: 'bg-green-100 text-green-800 hover:bg-green-200',
};
case 'degraded':
return {
label: 'Degraded Performance',
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
};
case 'maintenance':
return {
label: 'Under Maintenance',
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
};
case 'major_outage':
return {
label: 'Major Outage',
className: 'bg-red-100 text-red-800 hover:bg-red-200',
};
default:
return {
label: 'Unknown',
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
};
}
};
const config = getStatusConfig(status);
return (
<Badge className={config.className}>
{config.label}
</Badge>
);
};