diff --git a/README.md b/README.md index bebe8c4..4edee64 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Please refer to [Wiki](https://github.com/lyc8503/UptimeFlare/wiki) - [x] Remove old incidents - [x] ~~Known issue~~: `fetch` doesn't support non-standard port (resolved after CF update) - [x] Compatibility date update -- [x] Scheduled Maintenance +- [x] Scheduled Maintenance - [ ] Update wiki/README and add docs for dev - [ ] Migration to Terraform Cloudflare provider version 5.x - [ ] Cloudflare D1 database diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..9153254 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,31 @@ +import { Divider, Text } from '@mantine/core' + +export default function Footer() { + return ( + <> + + + Open-source monitoring and status page powered by{' '} + + Uptimeflare + {' '} + and{' '} + + Cloudflare + + , made with ❤ by{' '} + + lyc8503 + + . + + + ) +} diff --git a/components/Header.tsx b/components/Header.tsx index 2e6a149..12dd976 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -3,13 +3,13 @@ import classes from '@/styles/Header.module.css' import { pageConfig } from '@/uptime.config' import { PageConfigLink } from '@/types/config' -export default function Header() { - const linkToElement = (link: PageConfigLink) => { +export default function Header({ style }: { style?: React.CSSProperties }) { + const linkToElement = (link: PageConfigLink, i: number) => { return ( @@ -18,11 +18,16 @@ export default function Header() { ) } + const links = [{ label: 'Incident History', link: '/incidents' }, ...(pageConfig.links || [])] + return ( -
+
- + 🕒 @@ -39,11 +44,11 @@ export default function Header() {
- {pageConfig.links?.map(linkToElement)} + {links?.map(linkToElement)} - {pageConfig.links?.filter((link) => link.highlight).map(linkToElement)} + {links?.filter((link) => link.highlight || link.link.startsWith('/')).map(linkToElement)}
diff --git a/components/MaintenanceAlert.tsx b/components/MaintenanceAlert.tsx index 5919e0c..c4a84d7 100644 --- a/components/MaintenanceAlert.tsx +++ b/components/MaintenanceAlert.tsx @@ -1,4 +1,5 @@ -import { Alert, List, Text, Group, Box } from '@mantine/core' +import { Alert, List, Text, Group, Box, useMantineTheme } from '@mantine/core' +import { useMediaQuery } from '@mantine/hooks' import { IconAlertTriangle } from '@tabler/icons-react' import { MaintenanceConfig, MonitorTarget } from '@/types/config' import { pageConfig } from '@/uptime.config' @@ -12,6 +13,9 @@ export default function MaintenanceAlert({ style?: React.CSSProperties upcoming?: boolean }) { + const theme = useMantineTheme() + const isDesktop = useMediaQuery(`(min-width: ${theme.breakpoints.sm})`) + return ( } @@ -20,7 +24,7 @@ export default function MaintenanceAlert({ ? `Upcoming Maintenance: ${maintenance.title || 'Scheduled Maintenance'}` : maintenance.title || 'Scheduled Maintenance' } - color={upcoming ? (pageConfig.maintenances?.upcomingColor ?? 'gray') : maintenance.color || '#f29030'} + color={upcoming ? (pageConfig.maintenances?.upcomingColor ?? "gray") : maintenance.color || '#f29030'} withCloseButton={false} style={{ margin: '16px auto', ...style }} > @@ -34,7 +38,7 @@ export default function MaintenanceAlert({ [`@media (max-width: 600px)`]: { gap: 8 }, }} > - {/* Maintenance description and affected components */} + {/* Maintenance description and affected components */} {maintenance.body} @@ -55,18 +59,18 @@ export default function MaintenanceAlert({ style={{ flex: '0 0 auto', fontSize: '0.85rem', - textAlign: 'right', + textAlign: isDesktop ? 'right' : 'left', minWidth: 120, }} > - {upcoming ? 'Scheduled for' : 'From'}: {new Date(maintenance.start).toLocaleString()} -
- {upcoming ? 'Expected end' : 'To'}:{' '} - {maintenance.end - ? new Date(maintenance.end).toLocaleString() - : 'Until further notice'} + {upcoming ? 'Scheduled for' : 'From'}:{' '}{new Date(maintenance.start).toLocaleString()} +
+ {upcoming ? 'Expected end' : 'To'}:{' '} + {maintenance.end + ? new Date(maintenance.end).toLocaleString() + : 'Until further notice'}
) -} +} \ No newline at end of file diff --git a/components/MonitorList.tsx b/components/MonitorList.tsx index fdaf7e9..610caf0 100644 --- a/components/MonitorList.tsx +++ b/components/MonitorList.tsx @@ -42,7 +42,9 @@ export default function MonitorList({ // Load expanded groups from localStorage const savedExpandedGroups = localStorage.getItem('expandedGroups') - const expandedInitial = savedExpandedGroups ? JSON.parse(savedExpandedGroups) : Object.keys(group || {}) + const expandedInitial = savedExpandedGroups + ? JSON.parse(savedExpandedGroups) + : Object.keys(group || {}) const [expandedGroups, setExpandedGroups] = useState(expandedInitial) useEffect(() => { localStorage.setItem('expandedGroups', JSON.stringify(expandedGroups)) diff --git a/components/NoIncidents.tsx b/components/NoIncidents.tsx new file mode 100644 index 0000000..f3b8066 --- /dev/null +++ b/components/NoIncidents.tsx @@ -0,0 +1,29 @@ +import { Alert, Text } from '@mantine/core' +import { IconInfoCircle } from '@tabler/icons-react' + +export default function NoIncidentsAlert({ style }: { style?: React.CSSProperties }) { + return ( + } + title={ + + {'No incidents in this month'} + + } + color="gray" + withCloseButton={false} + style={{ + position: 'relative', + margin: '16px auto 0 auto', + ...style, + }} + > + There are no incidents for this month. + + ) +} diff --git a/components/OverallStatus.tsx b/components/OverallStatus.tsx index b20b2df..4955038 100644 --- a/components/OverallStatus.tsx +++ b/components/OverallStatus.tsx @@ -58,6 +58,7 @@ export default function OverallStatus({ }) const now = new Date() + const activeMaintenances: (Omit & { monitors?: MonitorTarget[] })[] = maintenances .filter((m) => now >= new Date(m.start) && (!m.end || now <= new Date(m.end))) .map((maintenance) => ({ diff --git a/pages/incidents.tsx b/pages/incidents.tsx new file mode 100644 index 0000000..98f3fb6 --- /dev/null +++ b/pages/incidents.tsx @@ -0,0 +1,131 @@ +import Head from 'next/head' + +import { Inter } from 'next/font/google' +import { MaintenanceConfig, MonitorTarget } from '@/types/config' +import { maintenances, pageConfig, workerConfig } from '@/uptime.config' +import Header from '@/components/Header' +import { Box, Button, Center, Container, Group, Select } from '@mantine/core' +import Footer from '@/components/Footer' +import { useEffect, useState } from 'react' +import MaintenanceAlert from '@/components/MaintenanceAlert' +import NoIncidentsAlert from '@/components/NoIncidents' + +export const runtime = 'experimental-edge' +const inter = Inter({ subsets: ['latin'] }) + +function getSelectedMonth() { + const hash = window.location.hash.replace('#', '') + if (!hash) { + const now = new Date() + return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + } + return hash.split('-').splice(0, 2).join('-') +} + +function filterIncidentsByMonth( + incidents: MaintenanceConfig[], + monthStr: string +): (Omit & { monitors: MonitorTarget[] })[] { + return incidents + .filter((incident) => { + const d = new Date(incident.start) + const incidentMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + return incidentMonth === monthStr + }) + .map((e) => ({ + ...e, + monitors: (e.monitors || []).map((e) => workerConfig.monitors.find((mon) => mon.id === e)!), + })) + .sort((a, b) => (new Date(a.start) > new Date(b.start) ? -1 : 1)) +} + +function getPrevNextMonth(monthStr: string) { + const [year, month] = monthStr.split('-').map(Number) + const date = new Date(year, month - 1) + const prev = new Date(date) + prev.setMonth(prev.getMonth() - 1) + const next = new Date(date) + next.setMonth(next.getMonth() + 1) + return { + prev: prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0'), + next: next.getFullYear() + '-' + String(next.getMonth() + 1).padStart(2, '0'), + } +} + +export default function IncidentsPage() { + const [selectedMonitor, setSelectedMonitor] = useState('') + const [selectedMonth, setSelectedMonth] = useState(getSelectedMonth()) + + useEffect(() => { + const onHashChange = () => setSelectedMonth(getSelectedMonth()) + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }, []) + + const filteredIncidents = filterIncidentsByMonth(maintenances, selectedMonth) + const monitorFilteredIncidents = selectedMonitor + ? filteredIncidents.filter((i) => i.monitors.find((e) => e.id === selectedMonitor)) + : filteredIncidents + + const { prev, next } = getPrevNextMonth(selectedMonth) + + const monitorOptions = [ + { value: '', label: 'All' }, + ...workerConfig.monitors.map((monitor) => ({ + value: monitor.id, + label: monitor.name, + })), + ] + + return ( + <> + + {pageConfig.title} + + + +
+
+
+ + +