mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
fix: improve the ui for the status page
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import type { PublicService } from '../api/status';
|
||||
import { useMediaQuery } from '../lib/useMediaQuery';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Desktop mirrors the API's full 90-day history. Compact screens use the most
|
||||
// recent 45 days so each daily bar remains legible.
|
||||
const HISTORY_DAYS = 90;
|
||||
const DAYS_PER_SEGMENT = 2;
|
||||
const HISTORY_SEGMENTS = HISTORY_DAYS / DAYS_PER_SEGMENT;
|
||||
const HISTORY_DAYS_COMPACT = 45;
|
||||
const COMPACT_QUERY = '(max-width: 520px)';
|
||||
|
||||
type HistoryEntry = PublicService['history'][number];
|
||||
|
||||
function dayClass(uptimePct: number | null) {
|
||||
if (uptimePct === null) return 'is-empty';
|
||||
function dayClass(uptimePct: number | null | undefined) {
|
||||
if (uptimePct === null || uptimePct === undefined) return 'is-empty';
|
||||
if (uptimePct === 100) return 'is-up';
|
||||
if (uptimePct === 0) return 'is-down';
|
||||
return 'is-partial';
|
||||
@@ -23,34 +26,31 @@ function formatDay(day: number) {
|
||||
}).format(new Date(day));
|
||||
}
|
||||
|
||||
function segmentTitle(startDay: number, endDay: number, uptimePct: number | null) {
|
||||
const dateRange = `${formatDay(startDay)} – ${formatDay(endDay)}`;
|
||||
return `${dateRange}: ${uptimePct === null ? 'No data' : `${uptimePct.toFixed(1)}% uptime`}`;
|
||||
function dayTitle(day: number, uptimePct: number | null | undefined) {
|
||||
const detail = uptimePct === null || uptimePct === undefined ? 'No data' : `${uptimePct.toFixed(1)}% uptime`;
|
||||
return `${formatDay(day)}: ${detail}`;
|
||||
}
|
||||
|
||||
export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) {
|
||||
const compact = useMediaQuery(COMPACT_QUERY);
|
||||
const windowDays = compact ? HISTORY_DAYS_COMPACT : HISTORY_DAYS;
|
||||
const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct]));
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const segments = Array.from({ length: HISTORY_SEGMENTS }, (_segment, index) => {
|
||||
const startDay = today - (HISTORY_DAYS - index * DAYS_PER_SEGMENT - 1) * DAY_MS;
|
||||
const endDay = startDay + (DAYS_PER_SEGMENT - 1) * DAY_MS;
|
||||
const values = Array.from({ length: DAYS_PER_SEGMENT }, (_day, dayIndex) => historyByDay.get(startDay + dayIndex * DAY_MS)).filter(
|
||||
(value): value is number => value !== undefined && value !== null,
|
||||
);
|
||||
const uptimePct = values.length === 0 ? null : Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10;
|
||||
return { startDay, endDay, uptimePct };
|
||||
const days = Array.from({ length: windowDays }, (_day, index) => {
|
||||
const day = today - (windowDays - 1 - index) * DAY_MS;
|
||||
return { day, uptimePct: historyByDay.get(day) };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="status-history">
|
||||
<div className="uptime-days" aria-label="90-day uptime shown in 45 two-day segments">
|
||||
{segments.map(({ startDay, endDay, uptimePct }) => (
|
||||
<span className={`uptime-day ${dayClass(uptimePct)}`} key={startDay} title={segmentTitle(startDay, endDay, uptimePct)} />
|
||||
<div className="uptime-days" aria-label={`Daily uptime over the last ${windowDays} days`}>
|
||||
{days.map(({ day, uptimePct }) => (
|
||||
<span className={`uptime-day ${dayClass(uptimePct)}`} key={day} title={dayTitle(day, uptimePct)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="uptime-days-caption" aria-hidden="true">
|
||||
<span>90 days ago</span>
|
||||
<span>{windowDays} days ago</span>
|
||||
<i />
|
||||
<span>Today</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function StatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : statusQuery.isError || !status ? (
|
||||
<Empty variant="error" className="mt-[42px] min-h-[280px] place-content-center p-12">
|
||||
<Empty variant="error" className="mt-10.5 min-h-70 place-content-center p-12">
|
||||
<EmptyMedia variant="icon">
|
||||
<TriangleAlert />
|
||||
</EmptyMedia>
|
||||
@@ -197,20 +197,20 @@ export function StatusPage() {
|
||||
const serviceStatus = SERVICE_STATUS[service.status];
|
||||
return (
|
||||
<article className="public-service-row" key={service.id}>
|
||||
<div className="public-service-summary">
|
||||
<span className="service-icon">
|
||||
<SiteIcon monitorId={service.id} favicon="public" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{service.name}</strong>
|
||||
<Badge className="mt-1.75" variant={serviceStatus.className}>
|
||||
{serviceStatus.label}
|
||||
</Badge>
|
||||
<div className="public-service-header">
|
||||
<div className="public-service-summary">
|
||||
<span className="service-icon">
|
||||
<SiteIcon monitorId={service.id} favicon="public" />
|
||||
</span>
|
||||
<div className="public-service-identity">
|
||||
<strong>{service.name}</strong>
|
||||
<Badge variant={serviceStatus.className}>{serviceStatus.label}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="public-service-uptime">
|
||||
<span>90-day uptime</span>
|
||||
<strong>{service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="public-service-uptime">
|
||||
<span>90-day uptime</span>
|
||||
<strong>{service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}</strong>
|
||||
</div>
|
||||
<StatusHistoryBar history={service.history} />
|
||||
</article>
|
||||
|
||||
+31
-21
@@ -1712,11 +1712,9 @@ button {
|
||||
color: var(--muted);
|
||||
}
|
||||
.public-service-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr);
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-height: 128px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
}
|
||||
.public-service-row + .public-service-row {
|
||||
@@ -1725,28 +1723,41 @@ button {
|
||||
.public-service-row:hover {
|
||||
background: #fdfefd;
|
||||
}
|
||||
.public-service-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.public-service-summary {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-service-summary > div {
|
||||
.public-service-identity {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.public-service-summary strong {
|
||||
display: block;
|
||||
.public-service-identity strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.public-service-summary .row-status {
|
||||
margin-top: 7px;
|
||||
.public-service-identity [data-slot='badge'] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.public-service-uptime {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.public-service-uptime span {
|
||||
display: block;
|
||||
@@ -1762,6 +1773,7 @@ button {
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.status-history {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.uptime-days {
|
||||
@@ -1773,7 +1785,7 @@ button {
|
||||
}
|
||||
.uptime-day {
|
||||
flex: 1 1 0;
|
||||
min-width: 1px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
background: #e6e8e7;
|
||||
transition:
|
||||
@@ -1830,11 +1842,10 @@ button {
|
||||
background: #fff;
|
||||
}
|
||||
.status-service-skeleton > div {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr minmax(240px, 0.8fr);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 116px;
|
||||
padding: 22px;
|
||||
}
|
||||
.status-service-skeleton > div + div {
|
||||
@@ -1858,6 +1869,7 @@ button {
|
||||
height: 34px;
|
||||
}
|
||||
.status-service-skeleton b {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
.status-footer {
|
||||
@@ -2070,12 +2082,8 @@ button {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
.public-service-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 20px;
|
||||
}
|
||||
.status-history {
|
||||
grid-column: 1 / -1;
|
||||
.uptime-days {
|
||||
gap: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2213,12 +2221,14 @@ button {
|
||||
.public-service-row {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.public-service-header {
|
||||
gap: 12px;
|
||||
}
|
||||
.public-service-uptime strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
.uptime-days {
|
||||
height: 28px;
|
||||
gap: 1px;
|
||||
}
|
||||
.status-footer {
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user