From 59fd9a7aaa18f107f9a64bf504c39e2f2bc06125 Mon Sep 17 00:00:00 2001 From: Emmanuel Meuwly Date: Sat, 20 Jun 2026 22:02:36 +0200 Subject: [PATCH] feat(public-status): show active incidents and scheduled maintenance The public status page only rendered live component health, so there was no way to communicate ongoing incidents or planned maintenance to visitors. The `incidents` and `maintenance` collections are already publicly readable, so this is a frontend-only addition. - add publicStatusService to fetch active (non-resolved) incidents and scheduled/in-progress maintenance, scoped to the page and its services - expose them through usePublicStatusPageData - render IncidentsSection and MaintenanceSection banners above the current status card (hidden when there is nothing to show) - add i18n keys to the public namespace (type interface, en, km) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/public/IncidentsSection.tsx | 78 +++++++++++++++++++ .../components/public/MaintenanceSection.tsx | 75 ++++++++++++++++++ .../components/public/PublicStatusPage.tsx | 10 ++- .../public/hooks/usePublicStatusPageData.ts | 22 +++++- .../src/services/publicStatusService.ts | 57 ++++++++++++++ application/src/translations/en/public.ts | 11 +++ application/src/translations/km/public.ts | 11 +++ application/src/translations/types/public.ts | 13 ++++ 8 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 application/src/components/public/IncidentsSection.tsx create mode 100644 application/src/components/public/MaintenanceSection.tsx create mode 100644 application/src/services/publicStatusService.ts diff --git a/application/src/components/public/IncidentsSection.tsx b/application/src/components/public/IncidentsSection.tsx new file mode 100644 index 00000000..1f9ad42a --- /dev/null +++ b/application/src/components/public/IncidentsSection.tsx @@ -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 ( + + + + + {t('activeIncidents', 'public')} + + + + {incidents.map((incident) => { + const service = services.find((s) => s.id === incident.service_id); + const affected = service?.name || incident.affected_systems; + return ( +
+
+

{incident.title}

+ + {impactStatusLabel(incident.impact_status, t)} + +
+ {incident.description && ( +

{incident.description}

+ )} +
+ {affected && ( + + {t('affectedLabel', 'public')}: {affected} + + )} + {incident.timestamp && ( + {format(new Date(incident.timestamp), 'MMM dd, yyyy HH:mm')} + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/application/src/components/public/MaintenanceSection.tsx b/application/src/components/public/MaintenanceSection.tsx new file mode 100644 index 00000000..6b1b5342 --- /dev/null +++ b/application/src/components/public/MaintenanceSection.tsx @@ -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 ( + + + + + {t('scheduledMaintenance', 'public')} + + + + {maintenance.map((item) => ( +
+
+

{item.title}

+ + {maintenanceStatusLabel(item.status, t)} + +
+ {item.description && ( +

{item.description}

+ )} +
+ {formatWindow(item.start_time, item.end_time) && ( + {formatWindow(item.start_time, item.end_time)} + )} + {item.affected && ( + + {t('affectedLabel', 'public')}: {item.affected} + + )} +
+
+ ))} +
+
+ ); +}; diff --git a/application/src/components/public/PublicStatusPage.tsx b/application/src/components/public/PublicStatusPage.tsx index a9ba99b2..477d56ed 100644 --- a/application/src/components/public/PublicStatusPage.tsx +++ b/application/src/components/public/PublicStatusPage.tsx @@ -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'; @@ -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 @@ -105,6 +107,12 @@ export const PublicStatusPage = () => { {/* Main Content */}
+ {/* Active Incidents */} + + + {/* Scheduled Maintenance */} + + {/* Current Status */} diff --git a/application/src/components/public/hooks/usePublicStatusPageData.ts b/application/src/components/public/hooks/usePublicStatusPageData.ts index a92e85bc..664b5f83 100644 --- a/application/src/components/public/hooks/usePublicStatusPageData.ts +++ b/application/src/components/public/hooks/usePublicStatusPageData.ts @@ -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(null); const [components, setComponents] = useState([]); const [services, setServices] = useState([]); const [uptimeData, setUptimeData] = useState>({}); + const [incidents, setIncidents] = useState([]); + const [maintenance, setMaintenance] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -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}`); @@ -104,6 +120,8 @@ export const usePublicStatusPageData = (slug: string | undefined) => { components, services, uptimeData, + incidents, + maintenance, loading, error }; diff --git a/application/src/services/publicStatusService.ts b/application/src/services/publicStatusService.ts new file mode 100644 index 00000000..0b5354c3 --- /dev/null +++ b/application/src/services/publicStatusService.ts @@ -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 { + 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 { + 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 []; + } + }, +}; diff --git a/application/src/translations/en/public.ts b/application/src/translations/en/public.ts index 2b5a2a9d..97a8e532 100644 --- a/application/src/translations/en/public.ts +++ b/application/src/translations/en/public.ts @@ -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", }; diff --git a/application/src/translations/km/public.ts b/application/src/translations/km/public.ts index 04a7c507..288a4486 100644 --- a/application/src/translations/km/public.ts +++ b/application/src/translations/km/public.ts @@ -18,6 +18,17 @@ export const publicTranslations: PublicTranslations = { notFoundDescription: "ទំព័រស្ថានភាពដែលបានស្នើរសុំមិនអាចរកឃើញ ឬមិនអាចចូលមើលជាសាធារណៈបាន។", goBack: "ត្រឡប់ក្រោយ", retry: "ព្យាយាមម្តងទៀត", + + activeIncidents: "ឧប្បត្តិហេតុកំពុងដំណើរការ", + affectedLabel: "ផ្នែករងផលប៉ះពាល់", + incidentInvestigating: "កំពុងស៊ើបអង្កេត", + incidentIdentified: "បានកំណត់អត្តសញ្ញាណ", + incidentFoundRootCause: "បានរកឃើញមូលហេតុ", + incidentMonitoring: "កំពុងតាមដាន", + + scheduledMaintenance: "ការថែទាំដែលបានកំណត់ពេល", + maintenanceScheduledStatus: "បានកំណត់ពេល", + maintenanceInProgress: "កំពុងដំណើរការ", }; diff --git a/application/src/translations/types/public.ts b/application/src/translations/types/public.ts index c4191cf2..61c20dff 100644 --- a/application/src/translations/types/public.ts +++ b/application/src/translations/types/public.ts @@ -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; }