Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c1cf472
added the total_downtime field to the incident model and made it avai…
ghengiskhanh Feb 21, 2026
1976f64
added availability view
ghengiskhanh Feb 21, 2026
e0647f0
fix for why saves were having errors
ghengiskhanh Feb 21, 2026
c4dfaa2
fixed formatting issues
ghengiskhanh Feb 21, 2026
d189551
fix for possible N+1 condition raised by Cursor
ghengiskhanh Feb 23, 2026
623ac2b
added filter to make sure incident is visible by the user
ghengiskhanh Feb 23, 2026
f67cc86
updated downtime state to an number
ghengiskhanh Feb 23, 2026
6dcc9fe
moved components out into their own files
ghengiskhanh Feb 23, 2026
a4b68a5
converted react rendering to use ternaries
ghengiskhanh Feb 23, 2026
7f808e5
using base enums for period
ghengiskhanh Feb 23, 2026
a3a4a7f
moved helper functionas into reporting util file
ghengiskhanh Feb 24, 2026
cdf5660
updated rollover colors to be the same as the main page
ghengiskhanh Feb 24, 2026
7f2cea0
moved downtime into it's own component file
ghengiskhanh Feb 24, 2026
fff5aef
reverted header changes; availability view can be reached in the url
ghengiskhanh Feb 24, 2026
b0ea997
fix for mypy test
ghengiskhanh Feb 24, 2026
b1e020c
Run prettier
spalmurray Feb 24, 2026
a3e851d
Run ruff format
spalmurray Feb 24, 2026
fd1016b
removed duplicate downtime formatting logic
ghengiskhanh Feb 25, 2026
1fd07f0
added check to make sure negative downtimes aren't submitted
ghengiskhanh Feb 26, 2026
e2d1cce
Merge branch 'main' into khanh/downtime-minutes-v2
ghengiskhanh Feb 27, 2026
69588d3
removed unused import
ghengiskhanh Mar 2, 2026
442bd98
Add Roboto Mono font files and @font-face declarations
spalmurray Mar 3, 2026
23bdb68
Add availability page with region heatmap cards and incident list links
spalmurray Mar 3, 2026
f4eeda6
Add Any status filter for frontend inc list view
spalmurray Mar 3, 2026
23f7d8c
Add pinned regions via config
spalmurray Mar 3, 2026
6ce9ff9
feat: Add heatmap links to filtered incident list (#120)
ghengiskhanh Mar 3, 2026
55ca13a
Merge branch 'main' into khanh/downtime-minutes-v2
spalmurray Mar 4, 2026
bd0ef74
Remove dead code
spalmurray Mar 4, 2026
42dac68
Fix bugs
spalmurray Mar 4, 2026
b7fad46
Fix more bugs
spalmurray Mar 4, 2026
e75038f
Fix rounding
spalmurray Mar 4, 2026
1e7ef04
Add tests for reporting_utils
spalmurray Mar 4, 2026
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
Binary file not shown.
Binary file not shown.
24 changes: 21 additions & 3 deletions frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AvailabilityIndexRouteImport } from './routes/availability/index'
import { Route as IncidentIdIndexRouteImport } from './routes/$incidentId/index'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AvailabilityIndexRoute = AvailabilityIndexRouteImport.update({
id: '/availability/',
path: '/availability/',
getParentRoute: () => rootRouteImport,
} as any)
const IncidentIdIndexRoute = IncidentIdIndexRouteImport.update({
id: '/$incidentId/',
path: '/$incidentId/',
Expand All @@ -26,27 +32,31 @@ const IncidentIdIndexRoute = IncidentIdIndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$incidentId': typeof IncidentIdIndexRoute
'/availability': typeof AvailabilityIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$incidentId': typeof IncidentIdIndexRoute
'/availability': typeof AvailabilityIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$incidentId/': typeof IncidentIdIndexRoute
'/availability/': typeof AvailabilityIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/$incidentId'
fullPaths: '/' | '/$incidentId' | '/availability'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/$incidentId'
id: '__root__' | '/' | '/$incidentId/'
to: '/' | '/$incidentId' | '/availability'
id: '__root__' | '/' | '/$incidentId/' | '/availability/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
IncidentIdIndexRoute: typeof IncidentIdIndexRoute
AvailabilityIndexRoute: typeof AvailabilityIndexRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -58,6 +68,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/availability/': {
id: '/availability/'
path: '/availability'
fullPath: '/availability'
preLoaderRoute: typeof AvailabilityIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/$incidentId/': {
id: '/$incidentId/'
path: '/$incidentId'
Expand All @@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
IncidentIdIndexRoute: IncidentIdIndexRoute,
AvailabilityIndexRoute: AvailabilityIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/routes/$incidentId/components/DowntimeField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
interface DowntimeFieldProps {
isEditing: boolean;
value: number | null;
draftValue: number | null;
onChange: (value: number | null) => void;
}

export function DowntimeField({
isEditing,
value,
draftValue,
onChange,
}: DowntimeFieldProps) {
return (
<div className="flex items-center gap-space-md">
<div className="text-content-secondary w-20 flex-none text-sm font-medium">
Downtime
</div>
<div className="flex flex-1 items-center justify-end">
{isEditing ? (
<div className="flex items-center gap-space-xs">
<input
type="number"
min="0"
value={draftValue ?? ''}
onChange={e => {
const num = e.target.valueAsNumber;
onChange(e.target.value === '' ? null : Number.isNaN(num) ? null : num);
}}
placeholder="—"
className="w-20 rounded-radius-sm border border-secondary bg-background-primary px-space-sm py-space-xs text-right text-sm focus:outline-none focus:ring-1"
/>
<span className="text-content-secondary text-sm">min</span>
</div>
) : (
<span
className={`text-sm ${value != null ? 'text-content-primary' : 'text-content-tertiary'}`}
>
{value != null ? `${value} min` : 'Not set'}
</span>
)}
</div>
</div>
);
}
20 changes: 20 additions & 0 deletions frontend/src/routes/$incidentId/components/MilestonesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {Pencil} from 'lucide-react';
import type {IncidentDetail} from '../queries/incidentDetailQueryOptions';
import {updateIncidentFieldMutationOptions} from '../queries/updateIncidentFieldMutationOptions';

import {DowntimeField} from './DowntimeField';

interface MilestonesCardProps {
incident: IncidentDetail;
}
Expand Down Expand Up @@ -71,6 +73,9 @@ function combineDateAndTime(date: Date, time: string): Date {

export function MilestonesCard({incident}: MilestonesCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [draftDowntime, setDraftDowntime] = useState<number | null>(
incident.total_downtime
);
const [draftValues, setDraftValues] = useState<DraftValues>(() => ({
time_started: parseIncidentDateTime(incident.time_started),
time_detected: parseIncidentDateTime(incident.time_detected),
Expand Down Expand Up @@ -98,6 +103,7 @@ export function MilestonesCard({incident}: MilestonesCardProps) {
};

const startEditing = () => {
setDraftDowntime(incident.total_downtime);
const drafts: DraftValues = {
time_started: parseIncidentDateTime(incident.time_started),
time_detected: parseIncidentDateTime(incident.time_detected),
Expand Down Expand Up @@ -147,6 +153,14 @@ export function MilestonesCard({incident}: MilestonesCardProps) {
});
}
}
if (draftDowntime !== incident.total_downtime) {
await updateIncidentField.mutateAsync({
incidentId: incident.id,
field: 'total_downtime',
value: draftDowntime,
});
}

setIsEditing(false);
} finally {
setIsSaving(false);
Expand Down Expand Up @@ -198,6 +212,12 @@ export function MilestonesCard({incident}: MilestonesCardProps) {
</div>
</div>
))}
<DowntimeField
isEditing={isEditing}
value={incident.total_downtime}
draftValue={draftDowntime}
onChange={setDraftDowntime}
/>
</div>
{isEditing && (
<div className="mt-space-lg flex items-center justify-end gap-space-xs">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const IncidentDetailSchema = z.object({
time_analyzed: z.string().nullable(),
time_mitigated: z.string().nullable(),
time_recovered: z.string().nullable(),
total_downtime: z.number().int().nullable(),
});

const IncidentOrRedirectSchema = z.union([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export type UpdateIncidentFieldArgs =
| 'time_mitigated'
| 'time_recovered';
value: string | null;
};
}
| {incidentId: string; field: 'total_downtime'; value: number | null};

const PatchResponseSchema = z.object({
id: z.string(),
Expand All @@ -59,6 +60,7 @@ const PatchResponseSchema = z.object({
time_analyzed: z.string().nullable(),
time_mitigated: z.string().nullable(),
time_recovered: z.string().nullable(),
total_downtime: z.number().int().nullable(),
Comment thread
ghengiskhanh marked this conversation as resolved.
});

export function updateIncidentFieldMutationOptions(queryClient: QueryClient) {
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/routes/availability/components/HeatmapBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {Link} from '@tanstack/react-router';
import {cn} from 'utils/cn';

import {getAvailabilityLevel, type AvailabilityLevel} from '../utils';

const AVAILABILITY_BG: Record<AvailabilityLevel, string> = {
success: 'bg-graphics-success-moderate',
warning: 'bg-graphics-warning-moderate',
danger: 'bg-graphics-danger-moderate',
};

interface HeatmapBlock {
label: string;
availability: number;
periodStart: string;
periodEnd: string;
regionName: string;
}

interface HeatmapBarProps {
blocks: HeatmapBlock[];
showEndLabels?: boolean;
}

export function HeatmapBar({blocks, showEndLabels}: HeatmapBarProps) {
return (
<div className="flex gap-px overflow-visible">
{blocks.map((block, i) => {
const isFullUptime = block.availability >= 100;
const displayPct = isFullUptime
? '100.00'
: Math.min(99.99, block.availability).toFixed(2);
const inner = (
<>
<div
className={cn(
'flex h-8 items-center justify-center sm:transition-opacity sm:group-hover:opacity-80',
AVAILABILITY_BG[getAvailabilityLevel(block.availability)],
i === 0 && 'rounded-l-md',
i === blocks.length - 1 && 'rounded-r-md'
)}
>
<span className="font-mono text-size-sm font-medium text-white drop-shadow-md">
{displayPct}%
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Availability display rounds below 100% to "100.00%"

Low Severity

block.availability.toFixed(2) rounds values like 99.997 to "100.00%", but the clickability condition uses block.availability >= 100. This creates blocks that display "100.00%" with a green success color yet are rendered as clickable Link elements pointing to an incident list. Any incident with a small total_downtime (e.g. 1 minute in a month-long period) triggers this inconsistency.

Additional Locations (1)

Fix in Cursor Fix in Web

</div>
<div
className={cn(
'pointer-events-none absolute top-full left-1/2 z-10 mt-0.5 -translate-x-1/2 whitespace-nowrap sm:block',
showEndLabels && i === blocks.length - 1
? 'opacity-100'
: 'hidden opacity-0 transition-opacity group-hover:opacity-100'
)}
>
<span className="text-size-xs text-content-secondary">{block.label}</span>
</div>
</>
);

return isFullUptime ? (
<div key={i} className="group relative min-w-0 flex-1">
{inner}
</div>
) : (
<Link
key={i}
className="group relative min-w-0 flex-1"
to="/"
search={{
affected_region: [block.regionName],
created_after: block.periodStart,
created_before: block.periodEnd,
service_tier: ['T0'],
status: ['Any'],
}}
>
{inner}
</Link>
);
})}
</div>
);
}
40 changes: 40 additions & 0 deletions frontend/src/routes/availability/components/PeriodTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Link} from '@tanstack/react-router';
import {cn} from 'utils/cn';

import {type Period} from '../queries/availabilityQueryOptions';

const TABS: {value: Period; label: string}[] = [
{value: 'month', label: 'Month'},
{value: 'quarter', label: 'Quarter'},
{value: 'year', label: 'Year'},
];

interface PeriodTabsProps {
activePeriod: Period;
}

export function PeriodTabs({activePeriod}: PeriodTabsProps) {
return (
<div className="gap-space-2xs flex">
{TABS.map(tab => (
<Link
key={tab.value}
to="/availability"
search={{period: tab.value}}
preload="intent"
className={cn(
'rounded-radius-sm px-space-lg py-space-sm text-size-sm font-medium transition-colors',
{
'bg-background-primary dark:bg-background-transparent-neutral-muted text-content-headings shadow-sm':
activePeriod === tab.value,
'text-content-secondary hover:text-black dark:hover:text-white':
activePeriod !== tab.value,
}
)}
>
{tab.label}
</Link>
))}
</div>
);
}
35 changes: 35 additions & 0 deletions frontend/src/routes/availability/components/RegionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {Card} from 'components/Card';

import {type PeriodData} from '../queries/availabilityQueryOptions';

import {HeatmapBar} from './HeatmapBar';

interface RegionCardProps {
regionName: string;
periods: PeriodData[];
showPeriodLabels?: boolean;
}

export function RegionCard({regionName, periods, showPeriodLabels}: RegionCardProps) {
const heatmapBlocks = [...periods].reverse().map(p => {
const region = p.regions.find(r => r.name === regionName);
return {
label: p.label,
availability: region?.availability_percentage ?? 100,
periodStart: p.start,
periodEnd: p.end,
regionName,
};
});

return (
<Card className="p-0">
<div className="flex flex-col gap-3 px-space-xl pt-3 pb-6">
<span className="text-content-headings text-size-lg font-semibold">
{regionName}
</span>
<HeatmapBar blocks={heatmapBlocks} showEndLabels={showPeriodLabels} />
</div>
</Card>
);
}
Loading