feat: incident history (#113)

* feat: add incident history link to links in header

* fix: lower margin-bottom on header to have not that much wasted space

* chore: move footer to own component

* feat: add NoIncidents component

* feat: add incidents history page

* fix: add sorting by date

* fix: fix sorting

* feat: hash linking to individual incidents

* fix: always truthy title

* feat: remove hash

* feat: some link improvements

* feat: improve styles for alert, support mobile devices

* fix: remove unused vars, larger margin

---------

Co-authored-by: lyc8503 <me@lyc8503.net>
pull/142/head
Bennet Gallein 2025-09-21 09:18:20 +02:00 committed by GitHub
parent 5769a52f0c
commit 41257c6ae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 235 additions and 42 deletions

31
components/Footer.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Divider, Text } from '@mantine/core'
export default function Footer() {
return (
<>
<Divider mt="lg" />
<Text
size="xs"
mt="xs"
mb="xs"
style={{
textAlign: 'center',
}}
>
Open-source monitoring and status page powered by{' '}
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
Uptimeflare
</a>{' '}
and{' '}
<a href="https://www.cloudflare.com/" target="_blank">
Cloudflare
</a>
, made with by{' '}
<a href="https://github.com/lyc8503" target="_blank">
lyc8503
</a>
.
</Text>
</>
)
}

View File

@ -4,12 +4,12 @@ import { pageConfig } from '@/uptime.config'
import { PageConfigLink } from '@/types/config'
export default function Header() {
const linkToElement = (link: PageConfigLink) => {
const linkToElement = (link: PageConfigLink, i: number) => {
return (
<a
key={link.label}
key={i}
href={link.link}
target="_blank"
target={link.link.startsWith('/') ? undefined : '_blank'}
className={classes.link}
data-active={link.highlight}
>
@ -18,11 +18,16 @@ export default function Header() {
)
}
const links = [{ label: 'Incident History', link: '/incidents' }, ...(pageConfig.links || [])]
return (
<header className={classes.header}>
<Container size="md" className={classes.inner}>
<div>
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
<a
href={location.pathname == '/' ? 'https://github.com/lyc8503/UptimeFlare' : '/'}
target={location.pathname == '/' ? '_blank' : undefined}
>
<Text size="xl" span>
🕒
</Text>
@ -39,11 +44,11 @@ export default function Header() {
</div>
<Group gap={5} visibleFrom="sm">
{pageConfig.links?.map(linkToElement)}
{links?.map(linkToElement)}
</Group>
<Group gap={5} hiddenFrom="sm">
{pageConfig.links?.filter((link) => link.highlight).map(linkToElement)}
{links?.filter((link) => (link.highlight || link.link.startsWith('/'))).map(linkToElement)}
</Group>
</Container>
</header>

View File

@ -1,4 +1,5 @@
import { Alert, List, Text } from '@mantine/core'
import { Alert, List, Text, useMantineTheme } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { IconAlertTriangle } from '@tabler/icons-react'
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
@ -9,24 +10,42 @@ export default function MaintenanceAlert({
maintenance: Omit<MaintenanceConfig, 'monitors'> & { monitors?: MonitorTarget[] }
style?: React.CSSProperties
}) {
const theme = useMantineTheme()
const isDesktop = useMediaQuery(`(min-width: ${theme.breakpoints.sm})`)
return (
<Alert
icon={<IconAlertTriangle />}
title={maintenance.title || 'Scheduled Maintenance'}
title={
<span
style={{
fontSize: '1rem',
fontWeight: 700,
}}
>
{maintenance.title || 'Scheduled Maintenance'}
</span>
}
color={maintenance.color || 'yellow'}
withCloseButton={false}
style={{ position: 'relative', margin: '16px auto 0 auto', ...style }}
>
{/* Date range in top right */}
{/* Date range in top right (desktop) or inline (mobile) */}
<div
style={{
position: 'absolute',
...{
top: 10,
right: 10,
fontSize: '0.85rem',
textAlign: 'right',
padding: '2px 8px',
borderRadius: 6,
},
...(isDesktop
? {
position: 'absolute',
right: 10,
padding: '2px 8px',
textAlign: 'right',
}
: {}),
}}
>
<b>From:</b> {new Date(maintenance.start).toLocaleString()}
@ -35,7 +54,7 @@ export default function MaintenanceAlert({
{maintenance.end ? new Date(maintenance.end).toLocaleString() : 'Until further notice'}
</div>
<Text>{maintenance.body}</Text>
<Text style={{ paddingTop: '3px' }}>{maintenance.body}</Text>
{maintenance.monitors && maintenance.monitors.length > 0 && (
<>
<Text mt="xs">

View File

@ -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 (
<Alert
icon={<IconInfoCircle />}
title={
<span
style={{
fontSize: '1rem',
fontWeight: 700,
}}
>
{'No incidents in this month'}
</span>
}
color="gray"
withCloseButton={false}
style={{
position: 'relative',
margin: '16px auto 0 auto',
...style,
}}
>
<Text>There are no incidents for this month.</Text>
</Alert>
)
}

130
pages/incidents.tsx Normal file
View File

@ -0,0 +1,130 @@
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<MaintenanceConfig, 'monitors'> & { 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<string | null>('')
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 (
<>
<Head>
<title>{pageConfig.title}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={inter.className}>
<Header />
<Center>
<Container size="md" style={{ width: '100%' }}>
<Group justify="end" mb="md">
<Select
placeholder="Select monitor"
data={monitorOptions}
value={selectedMonitor}
onChange={setSelectedMonitor}
clearable
style={{ maxWidth: 300, float: 'right' }}
/>
</Group>
<Box>
{monitorFilteredIncidents.length === 0 ? (
<NoIncidentsAlert />
) : (
monitorFilteredIncidents.map((incident, i) => (
<MaintenanceAlert
key={i}
maintenance={incident}
/>
))
)}
</Box>
<Group justify="space-between" mt="md">
<Button variant="default" onClick={() => (window.location.hash = prev)}>
Backwards
</Button>
<Box style={{ alignSelf: 'center', fontWeight: 500, fontSize: 18 }}>
{selectedMonth}
</Box>
<Button variant="default" onClick={() => (window.location.hash = next)}>
Forward
</Button>
</Group>
</Container>
</Center>
<Footer />
</main>
</>
)
}

View File

@ -7,8 +7,9 @@ import { maintenances, pageConfig, workerConfig } from '@/uptime.config'
import OverallStatus from '@/components/OverallStatus'
import Header from '@/components/Header'
import MonitorList from '@/components/MonitorList'
import { Center, Divider, Text } from '@mantine/core'
import { Center, Text } from '@mantine/core'
import MonitorDetail from '@/components/MonitorDetail'
import Footer from '@/components/Footer'
export const runtime = 'experimental-edge'
const inter = Inter({ subsets: ['latin'] })
@ -65,29 +66,7 @@ export default function Home({
</div>
)}
<Divider mt="lg" />
<Text
size="xs"
mt="xs"
mb="xs"
style={{
textAlign: 'center',
}}
>
Open-source monitoring and status page powered by{' '}
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
Uptimeflare
</a>{' '}
and{' '}
<a href="https://www.cloudflare.com/" target="_blank">
Cloudflare
</a>
, made with by{' '}
<a href="https://github.com/lyc8503" target="_blank">
lyc8503
</a>
.
</Text>
<Footer />
</main>
</>
)

View File

@ -1,6 +1,6 @@
.header {
height: rem(56px);
margin-bottom: rem(120px);
margin-bottom: rem(100px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}