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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions application/src/components/public/IncidentsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle } from 'lucide-react';
import { format } from 'date-fns';
import { IncidentItem } from '@/services/incident/types';
import { Service } from '@/types/service.types';
import { useLanguage } from '@/contexts/LanguageContext';

interface IncidentsSectionProps {
incidents: IncidentItem[];
services: Service[];
}

const impactStatusLabel = (
impactStatus: string | undefined,
t: (k: string, m?: string) => string
): string => {
switch (impactStatus) {
case 'investigating':
return t('incidentInvestigating', 'public');
case 'identified':
return t('incidentIdentified', 'public');
case 'found_root_cause':
return t('incidentFoundRootCause', 'public');
case 'monitoring':
return t('incidentMonitoring', 'public');
default:
return t('statusUnknown', 'public');
}
};

export const IncidentsSection = ({ incidents, services }: IncidentsSectionProps) => {
const { t } = useLanguage();

if (!incidents || incidents.length === 0) {
return null;
}

return (
<Card className="mb-8 border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
<AlertTriangle className="h-6 w-6 text-red-500" />
{t('activeIncidents', 'public')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{incidents.map((incident) => {
const service = services.find((s) => s.id === incident.service_id);
const affected = service?.name || incident.affected_systems;
return (
<div key={incident.id} className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<h3 className="font-semibold text-card-foreground">{incident.title}</h3>
<span className="shrink-0 px-3 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
{impactStatusLabel(incident.impact_status, t)}
</span>
</div>
{incident.description && (
<p className="text-sm text-muted-foreground mt-2">{incident.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground mt-3">
{affected && (
<span>
{t('affectedLabel', 'public')}: {affected}
</span>
)}
{incident.timestamp && (
<span>{format(new Date(incident.timestamp), 'MMM dd, yyyy HH:mm')}</span>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
};
75 changes: 75 additions & 0 deletions application/src/components/public/MaintenanceSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Wrench } from 'lucide-react';
import { format } from 'date-fns';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { useLanguage } from '@/contexts/LanguageContext';

interface MaintenanceSectionProps {
maintenance: MaintenanceItem[];
}

const maintenanceStatusLabel = (
status: string | undefined,
t: (k: string, m?: string) => string
): string => {
switch (status) {
case 'in_progress':
return t('maintenanceInProgress', 'public');
case 'scheduled':
return t('maintenanceScheduledStatus', 'public');
default:
return t('statusUnknown', 'public');
}
};

const formatWindow = (start?: string, end?: string): string => {
if (!start) return '';
const startText = format(new Date(start), 'MMM dd, yyyy HH:mm');
if (!end) return startText;
return `${startText} – ${format(new Date(end), 'MMM dd, yyyy HH:mm')}`;
};

export const MaintenanceSection = ({ maintenance }: MaintenanceSectionProps) => {
const { t } = useLanguage();

if (!maintenance || maintenance.length === 0) {
return null;
}

return (
<Card className="mb-8 border-2 border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
<Wrench className="h-6 w-6 text-blue-500" />
{t('scheduledMaintenance', 'public')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{maintenance.map((item) => (
<div key={item.id} className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<h3 className="font-semibold text-card-foreground">{item.title}</h3>
<span className="shrink-0 px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{maintenanceStatusLabel(item.status, t)}
</span>
</div>
{item.description && (
<p className="text-sm text-muted-foreground mt-2">{item.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground mt-3">
{formatWindow(item.start_time, item.end_time) && (
<span>{formatWindow(item.start_time, item.end_time)}</span>
)}
{item.affected && (
<span>
{t('affectedLabel', 'public')}: {item.affected}
</span>
)}
</div>
</div>
))}
</CardContent>
</Card>
);
};
10 changes: 9 additions & 1 deletion application/src/components/public/PublicStatusPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { RefreshCw, AlertCircle } from 'lucide-react';
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
import { StatusPageHeader } from './StatusPageHeader';
import { CurrentStatusSection } from './CurrentStatusSection';
import { IncidentsSection } from './IncidentsSection';
import { MaintenanceSection } from './MaintenanceSection';
import { ComponentsStatusSection } from './ComponentsStatusSection';
import { OverallUptimeSection } from './OverallUptimeSection';
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
Expand All @@ -16,7 +18,7 @@ export const PublicStatusPage = () => {
const { slug } = useParams<{ slug: string }>();
// console.log('PublicStatusPage - slug from params:', slug);

const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
const { page, components, services, uptimeData, incidents, maintenance, loading, error } = usePublicStatusPageData(slug);
const [lastUpdated, setLastUpdated] = useState(new Date());

// Auto-refresh every 30 seconds
Expand Down Expand Up @@ -105,6 +107,12 @@ export const PublicStatusPage = () => {

{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Active Incidents */}
<IncidentsSection incidents={incidents} services={services} />

{/* Scheduled Maintenance */}
<MaintenanceSection maintenance={maintenance} />

{/* Current Status */}
<CurrentStatusSection page={page} components={components} services={services} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@ import { useState, useEffect } from 'react';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service, UptimeData } from '@/types/service.types';
import { IncidentItem } from '@/services/incident/types';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { operationalPageService } from '@/services/operationalPageService';
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
import { serviceService } from '@/services/serviceService';
import { uptimeService } from '@/services/uptimeService';
import { publicStatusService } from '@/services/publicStatusService';

export const usePublicStatusPageData = (slug: string | undefined) => {
const [page, setPage] = useState<OperationalPageRecord | null>(null);
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
const [incidents, setIncidents] = useState<IncidentItem[]>([]);
const [maintenance, setMaintenance] = useState<MaintenanceItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -85,9 +90,20 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
});
setUptimeData(uptimeMap);
// console.log('Uptime data set successfully');


// Fetch active incidents and upcoming maintenance for this page
const serviceIds = pageComponents
.map(component => component.service_id)
.filter((id): id is string => Boolean(id));
const [activeIncidents, upcomingMaintenance] = await Promise.all([
publicStatusService.getActiveIncidents(foundPage.id, serviceIds),
publicStatusService.getUpcomingMaintenance(foundPage.id),
]);
setIncidents(activeIncidents);
setMaintenance(upcomingMaintenance);

// console.log('All data fetched successfully');

} catch (err) {
// console.error('Error fetching public page:', err);
setError(`Failed to load status page: ${err}`);
Expand All @@ -104,6 +120,8 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
components,
services,
uptimeData,
incidents,
maintenance,
loading,
error
};
Expand Down
57 changes: 57 additions & 0 deletions application/src/services/publicStatusService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@

import { pb } from '@/lib/pocketbase';
import { IncidentItem } from './incident/types';
import { MaintenanceItem } from './types/maintenance.types';

/**
* Read-only data for the public status page.
*
* The `incidents` and `maintenance` collections are publicly readable
* (list/view rules are open), so these queries work without authentication.
* Records are scoped to the operational page being viewed: either linked to
* the page directly (`operational_status_id`) or to one of the services shown
* on that page (`service_id`).
*/

// OR-filter that scopes records to a page and/or its services.
const scopeFilter = (pageId: string, serviceIds: string[]): string => {
const clauses = [
`operational_status_id='${pageId}'`,
...serviceIds.map((id) => `service_id='${id}'`),
];
return `(${clauses.join(' || ')})`;
};

export const publicStatusService = {
// Active (not yet resolved) incidents relevant to this page.
async getActiveIncidents(pageId: string, serviceIds: string[]): Promise<IncidentItem[]> {
try {
const filter = `${scopeFilter(pageId, serviceIds)} && impact_status != 'resolved'`;
const result = await pb.collection('incidents').getList(1, 50, {
sort: '-timestamp',
filter,
requestKey: null,
});
return result.items as unknown as IncidentItem[];
} catch (error) {
console.error('Error fetching public incidents:', error);
return [];
}
},

// Scheduled or in-progress maintenance windows for this page.
async getUpcomingMaintenance(pageId: string): Promise<MaintenanceItem[]> {
try {
const filter = `operational_status_id='${pageId}' && (status='scheduled' || status='in_progress')`;
const result = await pb.collection('maintenance').getList(1, 50, {
sort: 'start_time',
filter,
requestKey: null,
});
return result.items as unknown as MaintenanceItem[];
} catch (error) {
console.error('Error fetching public maintenance:', error);
return [];
}
},
};
11 changes: 11 additions & 0 deletions application/src/translations/en/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export const publicTranslations: PublicTranslations = {
notFoundDescription: "The requested status page could not be found or is not publicly accessible.",
goBack: "Go Back",
retry: "Retry",

activeIncidents: "Active Incidents",
affectedLabel: "Affected",
incidentInvestigating: "Investigating",
incidentIdentified: "Identified",
incidentFoundRootCause: "Root cause found",
incidentMonitoring: "Monitoring",

scheduledMaintenance: "Scheduled Maintenance",
maintenanceScheduledStatus: "Scheduled",
maintenanceInProgress: "In progress",
};


11 changes: 11 additions & 0 deletions application/src/translations/km/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export const publicTranslations: PublicTranslations = {
notFoundDescription: "ទំព័រស្ថានភាពដែលបានស្នើរសុំមិនអាចរកឃើញ ឬមិនអាចចូលមើលជាសាធារណៈបាន។",
goBack: "ត្រឡប់ក្រោយ",
retry: "ព្យាយាមម្តងទៀត",

activeIncidents: "ឧប្បត្តិហេតុកំពុងដំណើរការ",
affectedLabel: "ផ្នែករងផលប៉ះពាល់",
incidentInvestigating: "កំពុងស៊ើបអង្កេត",
incidentIdentified: "បានកំណត់អត្តសញ្ញាណ",
incidentFoundRootCause: "បានរកឃើញមូលហេតុ",
incidentMonitoring: "កំពុងតាមដាន",

scheduledMaintenance: "ការថែទាំដែលបានកំណត់ពេល",
maintenanceScheduledStatus: "បានកំណត់ពេល",
maintenanceInProgress: "កំពុងដំណើរការ",
};


13 changes: 13 additions & 0 deletions application/src/translations/types/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export interface PublicTranslations {
notFoundDescription: string;
goBack: string;
retry: string;

// IncidentsSection
activeIncidents: string;
affectedLabel: string;
incidentInvestigating: string;
incidentIdentified: string;
incidentFoundRootCause: string;
incidentMonitoring: string;

// MaintenanceSection
scheduledMaintenance: string;
maintenanceScheduledStatus: string;
maintenanceInProgress: string;
}