Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
| 32 | 32 | | `icon-transparent-1024.png` | 1024×1024, transparent | Placing the icon on a dark surface of your own | | |
| 33 | 33 | | `logo-on-dark.png` | 1056×642, dark background | Stripe branding logo (wide), docs and decks on dark | | |
| 34 | 34 | | `logo-on-light.png` | 1056×642, white background | Invoices, receipts and anywhere the background is light | | |
| 35 | − | | `og-1200x630.png` | 1200×630 | Social and Open Graph card | | |
| 36 | 35 | ||
| 37 | 36 | ## Sources | |
| 38 | 37 | ||
| 49 | 48 | | `svg/favicon.svg` | The 16px cut with no tile, coloured for a light or dark browser (`favicon.svg` in the apps and `packages/theme`) | | |
| 50 | 49 | | `svg/logo.svg`, `svg/logo-on-light.svg` | The wordmark, transparent and cropped to its ink, for dark and light backgrounds | | |
| 51 | 50 | | `svg/logo-dark-bg.svg`, `svg/logo-light-bg.svg` | The wordmark with padding and a background, on 40px cells | | |
| 52 | − | | `svg/og.svg` | The social card; its tagline uses Hanken Grotesk and IBM Plex Mono as live text | | |
| 53 | 51 | ||
| 54 | 52 | `favicon.ico` in the apps holds the 16, 32 and 48px cuts of the icon on a | |
| 55 | 53 | rounded dark tile. The pixels are squares, so no file needs a font. | |
| 62 | 60 | - `g1t-mark.svg`, `g1t-mark.png` (256px): the icon on a rounded dark tile, readable on any background | |
| 63 | 61 | - `g1t-logo.svg`, `g1t-logo.png` (300×140, shown at 60×28): the wordmark for light backgrounds, used in g1t's emails | |
| 64 | 62 | - `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png` (300×140): the wordmark for dark backgrounds | |
| 65 | − | - `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails | |
| 66 | 63 | ||
| 67 | 64 | ## Social cards | |
| 68 | 65 | ||
| 70 | 67 | drawn by `services/og` in the same colours, type and pixels: the wordmark and | |
| 71 | 68 | the page's address along the top, its subject in the middle, its facts along | |
| 72 | 69 | the bottom, the pixel 1 standing large and faded off the right edge. | |
| 70 | + | ||
| 71 | + | There is no static social card. The brand card, the one for g1t.sh itself | |
| 72 | + | and for any page without a card of its own, is `https://og.g1t.sh/image?path=%2F`, | |
| 73 | + | with the lockup and the line "Where people and agents ship software | |
| 74 | + | together." When a card cannot be drawn, og draws the brand card instead. | |
| 75 | + | ||
| 76 | + | Every card's address carries the render version in its `v` parameter | |
| 77 | + | (`OG_RENDER_VERSION` in `packages/contracts/src/og.ts`), which is also part | |
| 78 | + | of og's cache key. Bump it in the same change as any change to the cards' | |
| 79 | + | design, the logo or the brand line: otherwise og, browsers and every site | |
| 80 | + | that has fetched a card keep showing the old one. |
Binary or large file; its contents are not shown.
| 1 | − | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630"><rect width="1200" height="630" fill="#0f0f11"/><g fill="#2a2a30"><circle cx="20" cy="15" r="1.4"/><circle cx="20" cy="55" r="1.4"/><circle cx="20" cy="95" r="1.4"/><circle cx="20" cy="135" r="1.4"/><circle cx="20" cy="175" r="1.4"/><circle cx="20" cy="215" r="1.4"/><circle cx="20" cy="255" r="1.4"/><circle cx="20" cy="295" r="1.4"/><circle cx="20" cy="335" r="1.4"/><circle cx="20" cy="375" r="1.4"/><circle cx="20" cy="415" r="1.4"/><circle cx="20" cy="455" r="1.4"/><circle cx="20" cy="495" r="1.4"/><circle cx="20" cy="535" r="1.4"/><circle cx="20" cy="575" r="1.4"/><circle cx="20" cy="615" r="1.4"/><circle cx="60" cy="15" r="1.4"/><circle cx="60" cy="55" r="1.4"/><circle cx="60" cy="95" r="1.4"/><circle cx="60" cy="135" r="1.4"/><circle cx="60" cy="175" r="1.4"/><circle cx="60" cy="215" r="1.4"/><circle cx="60" cy="255" r="1.4"/><circle cx="60" cy="295" r="1.4"/><circle cx="60" cy="335" r="1.4"/><circle cx="60" cy="375" r="1.4"/><circle cx="60" cy="415" r="1.4"/><circle cx="60" cy="455" r="1.4"/><circle cx="60" cy="495" r="1.4"/><circle cx="60" cy="535" r="1.4"/><circle cx="60" cy="575" r="1.4"/><circle cx="60" cy="615" r="1.4"/><circle cx="100" cy="15" r="1.4"/><circle cx="100" cy="55" r="1.4"/><circle cx="100" cy="95" r="1.4"/><circle cx="100" cy="135" r="1.4"/><circle cx="100" cy="175" r="1.4"/><circle cx="100" cy="215" r="1.4"/><circle cx="100" cy="255" r="1.4"/><circle cx="100" cy="295" r="1.4"/><circle cx="100" cy="335" r="1.4"/><circle cx="100" cy="375" r="1.4"/><circle cx="100" cy="415" r="1.4"/><circle cx="100" cy="455" r="1.4"/><circle cx="100" cy="495" r="1.4"/><circle cx="100" cy="535" r="1.4"/><circle cx="100" cy="575" r="1.4"/><circle cx="100" cy="615" r="1.4"/><circle cx="140" cy="15" r="1.4"/><circle cx="140" cy="55" r="1.4"/><circle cx="140" cy="95" r="1.4"/><circle cx="140" cy="135" r="1.4"/><circle cx="140" cy="175" r="1.4"/><circle cx="140" cy="215" r="1.4"/><circle cx="140" cy="255" r="1.4"/><circle cx="140" cy="295" r="1.4"/><circle cx="140" cy="335" r="1.4"/><circle cx="140" cy="375" r="1.4"/><circle cx="140" cy="415" r="1.4"/><circle cx="140" cy="455" r="1.4"/><circle cx="140" cy="495" r="1.4"/><circle cx="140" cy="535" r="1.4"/><circle cx="140" cy="575" r="1.4"/><circle cx="140" cy="615" r="1.4"/><circle cx="180" cy="15" r="1.4"/><circle cx="180" cy="55" r="1.4"/><circle cx="180" cy="95" r="1.4"/><circle cx="180" cy="135" r="1.4"/><circle cx="180" cy="175" r="1.4"/><circle cx="180" cy="215" r="1.4"/><circle cx="180" cy="255" r="1.4"/><circle cx="180" cy="295" r="1.4"/><circle cx="180" cy="335" r="1.4"/><circle cx="180" cy="375" r="1.4"/><circle cx="180" cy="415" r="1.4"/><circle cx="180" cy="455" r="1.4"/><circle cx="180" cy="495" r="1.4"/><circle cx="180" cy="535" r="1.4"/><circle cx="180" cy="575" r="1.4"/><circle cx="180" cy="615" r="1.4"/><circle cx="220" cy="15" r="1.4"/><circle cx="220" cy="55" r="1.4"/><circle cx="220" cy="95" r="1.4"/><circle cx="220" cy="135" r="1.4"/><circle cx="220" cy="175" r="1.4"/><circle cx="220" cy="215" r="1.4"/><circle cx="220" cy="255" r="1.4"/><circle cx="220" cy="295" r="1.4"/><circle cx="220" cy="335" r="1.4"/><circle cx="220" cy="375" r="1.4"/><circle cx="220" cy="415" r="1.4"/><circle cx="220" cy="455" r="1.4"/><circle cx="220" cy="495" r="1.4"/><circle cx="220" cy="535" r="1.4"/><circle cx="220" cy="575" r="1.4"/><circle cx="220" cy="615" r="1.4"/><circle cx="260" cy="15" r="1.4"/><circle cx="260" cy="55" r="1.4"/><circle cx="260" cy="95" r="1.4"/><circle cx="260" cy="135" r="1.4"/><circle cx="260" cy="175" r="1.4"/><circle cx="260" cy="215" r="1.4"/><circle cx="260" cy="255" r="1.4"/><circle cx="260" cy="295" r="1.4"/><circle cx="260" cy="335" r="1.4"/><circle cx="260" cy="375" r="1.4"/><circle cx="260" cy="415" r="1.4"/><circle cx="260" cy="455" r="1.4"/><circle cx="260" cy="495" r="1.4"/><circle cx="260" cy="535" r="1.4"/><circle cx="260" cy="575" r="1.4"/><circle cx="260" cy="615" r="1.4"/><circle cx="300" cy="15" r="1.4"/><circle cx="300" cy="55" r="1.4"/><circle cx="300" cy="95" r="1.4"/><circle cx="300" cy="135" r="1.4"/><circle cx="300" cy="175" r="1.4"/><circle cx="300" cy="215" r="1.4"/><circle cx="300" cy="255" r="1.4"/><circle cx="300" cy="295" r="1.4"/><circle cx="300" cy="335" r="1.4"/><circle cx="300" cy="375" r="1.4"/><circle cx="300" cy="415" r="1.4"/><circle cx="300" cy="455" r="1.4"/><circle cx="300" cy="495" r="1.4"/><circle cx="300" cy="535" r="1.4"/><circle cx="300" cy="575" r="1.4"/><circle cx="300" cy="615" r="1.4"/><circle cx="340" cy="15" r="1.4"/><circle cx="340" cy="55" r="1.4"/><circle cx="340" cy="95" r="1.4"/><circle cx="340" cy="135" r="1.4"/><circle cx="340" cy="175" r="1.4"/><circle cx="340" cy="215" r="1.4"/><circle cx="340" cy="255" r="1.4"/><circle cx="340" cy="295" r="1.4"/><circle cx="340" cy="335" r="1.4"/><circle cx="340" cy="375" r="1.4"/><circle cx="340" cy="415" r="1.4"/><circle cx="340" cy="455" r="1.4"/><circle cx="340" cy="495" r="1.4"/><circle cx="340" cy="535" r="1.4"/><circle cx="340" cy="575" r="1.4"/><circle cx="340" cy="615" r="1.4"/><circle cx="380" cy="15" r="1.4"/><circle cx="380" cy="55" r="1.4"/><circle cx="380" cy="95" r="1.4"/><circle cx="380" cy="135" r="1.4"/><circle cx="380" cy="175" r="1.4"/><circle cx="380" cy="215" r="1.4"/><circle cx="380" cy="255" r="1.4"/><circle cx="380" cy="295" r="1.4"/><circle cx="380" cy="335" r="1.4"/><circle cx="380" cy="375" r="1.4"/><circle cx="380" cy="415" r="1.4"/><circle cx="380" cy="455" r="1.4"/><circle cx="380" cy="495" r="1.4"/><circle cx="380" cy="535" r="1.4"/><circle cx="380" cy="575" r="1.4"/><circle cx="380" cy="615" r="1.4"/><circle cx="420" cy="15" r="1.4"/><circle cx="420" cy="55" r="1.4"/><circle cx="420" cy="95" r="1.4"/><circle cx="420" cy="135" r="1.4"/><circle cx="420" cy="175" r="1.4"/><circle cx="420" cy="215" r="1.4"/><circle cx="420" cy="255" r="1.4"/><circle cx="420" cy="295" r="1.4"/><circle cx="420" cy="335" r="1.4"/><circle cx="420" cy="375" r="1.4"/><circle cx="420" cy="415" r="1.4"/><circle cx="420" cy="455" r="1.4"/><circle cx="420" cy="495" r="1.4"/><circle cx="420" cy="535" r="1.4"/><circle cx="420" cy="575" r="1.4"/><circle cx="420" cy="615" r="1.4"/><circle cx="460" cy="15" r="1.4"/><circle cx="460" cy="55" r="1.4"/><circle cx="460" cy="95" r="1.4"/><circle cx="460" cy="135" r="1.4"/><circle cx="460" cy="175" r="1.4"/><circle cx="460" cy="215" r="1.4"/><circle cx="460" cy="255" r="1.4"/><circle cx="460" cy="295" r="1.4"/><circle cx="460" cy="335" r="1.4"/><circle cx="460" cy="375" r="1.4"/><circle cx="460" cy="415" r="1.4"/><circle cx="460" cy="455" r="1.4"/><circle cx="460" cy="495" r="1.4"/><circle cx="460" cy="535" r="1.4"/><circle cx="460" cy="575" r="1.4"/><circle cx="460" cy="615" r="1.4"/><circle cx="500" cy="15" r="1.4"/><circle cx="500" cy="55" r="1.4"/><circle cx="500" cy="95" r="1.4"/><circle cx="500" cy="135" r="1.4"/><circle cx="500" cy="175" r="1.4"/><circle cx="500" cy="215" r="1.4"/><circle cx="500" cy="255" r="1.4"/><circle cx="500" cy="295" r="1.4"/><circle cx="500" cy="335" r="1.4"/><circle cx="500" cy="375" r="1.4"/><circle cx="500" cy="415" r="1.4"/><circle cx="500" cy="455" r="1.4"/><circle cx="500" cy="495" r="1.4"/><circle cx="500" cy="535" r="1.4"/><circle cx="500" cy="575" r="1.4"/><circle cx="500" cy="615" r="1.4"/><circle cx="540" cy="15" r="1.4"/><circle cx="540" cy="55" r="1.4"/><circle cx="540" cy="95" r="1.4"/><circle cx="540" cy="135" r="1.4"/><circle cx="540" cy="175" r="1.4"/><circle cx="540" cy="215" r="1.4"/><circle cx="540" cy="255" r="1.4"/><circle cx="540" cy="295" r="1.4"/><circle cx="540" cy="335" r="1.4"/><circle cx="540" cy="375" r="1.4"/><circle cx="540" cy="415" r="1.4"/><circle cx="540" cy="455" r="1.4"/><circle cx="540" cy="495" r="1.4"/><circle cx="540" cy="535" r="1.4"/><circle cx="540" cy="575" r="1.4"/><circle cx="540" cy="615" r="1.4"/><circle cx="580" cy="15" r="1.4"/><circle cx="580" cy="55" r="1.4"/><circle cx="580" cy="95" r="1.4"/><circle cx="580" cy="135" r="1.4"/><circle cx="580" cy="175" r="1.4"/><circle cx="580" cy="215" r="1.4"/><circle cx="580" cy="255" r="1.4"/><circle cx="580" cy="295" r="1.4"/><circle cx="580" cy="335" r="1.4"/><circle cx="580" cy="375" r="1.4"/><circle cx="580" cy="415" r="1.4"/><circle cx="580" cy="455" r="1.4"/><circle cx="580" cy="495" r="1.4"/><circle cx="580" cy="535" r="1.4"/><circle cx="580" cy="575" r="1.4"/><circle cx="580" cy="615" r="1.4"/><circle cx="620" cy="15" r="1.4"/><circle cx="620" cy="55" r="1.4"/><circle cx="620" cy="95" r="1.4"/><circle cx="620" cy="135" r="1.4"/><circle cx="620" cy="175" r="1.4"/><circle cx="620" cy="215" r="1.4"/><circle cx="620" cy="255" r="1.4"/><circle cx="620" cy="295" r="1.4"/><circle cx="620" cy="335" r="1.4"/><circle cx="620" cy="375" r="1.4"/><circle cx="620" cy="415" r="1.4"/><circle cx="620" cy="455" r="1.4"/><circle cx="620" cy="495" r="1.4"/><circle cx="620" cy="535" r="1.4"/><circle cx="620" cy="575" r="1.4"/><circle cx="620" cy="615" r="1.4"/><circle cx="660" cy="15" r="1.4"/><circle cx="660" cy="55" r="1.4"/><circle cx="660" cy="95" r="1.4"/><circle cx="660" cy="135" r="1.4"/><circle cx="660" cy="175" r="1.4"/><circle cx="660" cy="215" r="1.4"/><circle cx="660" cy="255" r="1.4"/><circle cx="660" cy="295" r="1.4"/><circle cx="660" cy="335" r="1.4"/><circle cx="660" cy="375" r="1.4"/><circle cx="660" cy="415" r="1.4"/><circle cx="660" cy="455" r="1.4"/><circle cx="660" cy="495" r="1.4"/><circle cx="660" cy="535" r="1.4"/><circle cx="660" cy="575" r="1.4"/><circle cx="660" cy="615" r="1.4"/><circle cx="700" cy="15" r="1.4"/><circle cx="700" cy="55" r="1.4"/><circle cx="700" cy="95" r="1.4"/><circle cx="700" cy="135" r="1.4"/><circle cx="700" cy="175" r="1.4"/><circle cx="700" cy="215" r="1.4"/><circle cx="700" cy="255" r="1.4"/><circle cx="700" cy="295" r="1.4"/><circle cx="700" cy="335" r="1.4"/><circle cx="700" cy="375" r="1.4"/><circle cx="700" cy="415" r="1.4"/><circle cx="700" cy="455" r="1.4"/><circle cx="700" cy="495" r="1.4"/><circle cx="700" cy="535" r="1.4"/><circle cx="700" cy="575" r="1.4"/><circle cx="700" cy="615" r="1.4"/><circle cx="740" cy="15" r="1.4"/><circle cx="740" cy="55" r="1.4"/><circle cx="740" cy="95" r="1.4"/><circle cx="740" cy="135" r="1.4"/><circle cx="740" cy="175" r="1.4"/><circle cx="740" cy="215" r="1.4"/><circle cx="740" cy="255" r="1.4"/><circle cx="740" cy="295" r="1.4"/><circle cx="740" cy="335" r="1.4"/><circle cx="740" cy="375" r="1.4"/><circle cx="740" cy="415" r="1.4"/><circle cx="740" cy="455" r="1.4"/><circle cx="740" cy="495" r="1.4"/><circle cx="740" cy="535" r="1.4"/><circle cx="740" cy="575" r="1.4"/><circle cx="740" cy="615" r="1.4"/><circle cx="780" cy="15" r="1.4"/><circle cx="780" cy="55" r="1.4"/><circle cx="780" cy="95" r="1.4"/><circle cx="780" cy="135" r="1.4"/><circle cx="780" cy="175" r="1.4"/><circle cx="780" cy="215" r="1.4"/><circle cx="780" cy="255" r="1.4"/><circle cx="780" cy="295" r="1.4"/><circle cx="780" cy="335" r="1.4"/><circle cx="780" cy="375" r="1.4"/><circle cx="780" cy="415" r="1.4"/><circle cx="780" cy="455" r="1.4"/><circle cx="780" cy="495" r="1.4"/><circle cx="780" cy="535" r="1.4"/><circle cx="780" cy="575" r="1.4"/><circle cx="780" cy="615" r="1.4"/><circle cx="820" cy="15" r="1.4"/><circle cx="820" cy="55" r="1.4"/><circle cx="820" cy="95" r="1.4"/><circle cx="820" cy="135" r="1.4"/><circle cx="820" cy="175" r="1.4"/><circle cx="820" cy="215" r="1.4"/><circle cx="820" cy="255" r="1.4"/><circle cx="820" cy="295" r="1.4"/><circle cx="820" cy="335" r="1.4"/><circle cx="820" cy="375" r="1.4"/><circle cx="820" cy="415" r="1.4"/><circle cx="820" cy="455" r="1.4"/><circle cx="820" cy="495" r="1.4"/><circle cx="820" cy="535" r="1.4"/><circle cx="820" cy="575" r="1.4"/><circle cx="820" cy="615" r="1.4"/><circle cx="860" cy="15" r="1.4"/><circle cx="860" cy="55" r="1.4"/><circle cx="860" cy="95" r="1.4"/><circle cx="860" cy="135" r="1.4"/><circle cx="860" cy="175" r="1.4"/><circle cx="860" cy="215" r="1.4"/><circle cx="860" cy="255" r="1.4"/><circle cx="860" cy="295" r="1.4"/><circle cx="860" cy="335" r="1.4"/><circle cx="860" cy="375" r="1.4"/><circle cx="860" cy="415" r="1.4"/><circle cx="860" cy="455" r="1.4"/><circle cx="860" cy="495" r="1.4"/><circle cx="860" cy="535" r="1.4"/><circle cx="860" cy="575" r="1.4"/><circle cx="860" cy="615" r="1.4"/><circle cx="900" cy="15" r="1.4"/><circle cx="900" cy="55" r="1.4"/><circle cx="900" cy="95" r="1.4"/><circle cx="900" cy="135" r="1.4"/><circle cx="900" cy="175" r="1.4"/><circle cx="900" cy="215" r="1.4"/><circle cx="900" cy="255" r="1.4"/><circle cx="900" cy="295" r="1.4"/><circle cx="900" cy="335" r="1.4"/><circle cx="900" cy="375" r="1.4"/><circle cx="900" cy="415" r="1.4"/><circle cx="900" cy="455" r="1.4"/><circle cx="900" cy="495" r="1.4"/><circle cx="900" cy="535" r="1.4"/><circle cx="900" cy="575" r="1.4"/><circle cx="900" cy="615" r="1.4"/><circle cx="940" cy="15" r="1.4"/><circle cx="940" cy="55" r="1.4"/><circle cx="940" cy="95" r="1.4"/><circle cx="940" cy="135" r="1.4"/><circle cx="940" cy="175" r="1.4"/><circle cx="940" cy="215" r="1.4"/><circle cx="940" cy="255" r="1.4"/><circle cx="940" cy="295" r="1.4"/><circle cx="940" cy="335" r="1.4"/><circle cx="940" cy="375" r="1.4"/><circle cx="940" cy="415" r="1.4"/><circle cx="940" cy="455" r="1.4"/><circle cx="940" cy="495" r="1.4"/><circle cx="940" cy="535" r="1.4"/><circle cx="940" cy="575" r="1.4"/><circle cx="940" cy="615" r="1.4"/><circle cx="980" cy="15" r="1.4"/><circle cx="980" cy="55" r="1.4"/><circle cx="980" cy="95" r="1.4"/><circle cx="980" cy="135" r="1.4"/><circle cx="980" cy="175" r="1.4"/><circle cx="980" cy="215" r="1.4"/><circle cx="980" cy="255" r="1.4"/><circle cx="980" cy="295" r="1.4"/><circle cx="980" cy="335" r="1.4"/><circle cx="980" cy="375" r="1.4"/><circle cx="980" cy="415" r="1.4"/><circle cx="980" cy="455" r="1.4"/><circle cx="980" cy="495" r="1.4"/><circle cx="980" cy="535" r="1.4"/><circle cx="980" cy="575" r="1.4"/><circle cx="980" cy="615" r="1.4"/><circle cx="1020" cy="15" r="1.4"/><circle cx="1020" cy="55" r="1.4"/><circle cx="1020" cy="95" r="1.4"/><circle cx="1020" cy="135" r="1.4"/><circle cx="1020" cy="175" r="1.4"/><circle cx="1020" cy="215" r="1.4"/><circle cx="1020" cy="255" r="1.4"/><circle cx="1020" cy="295" r="1.4"/><circle cx="1020" cy="335" r="1.4"/><circle cx="1020" cy="375" r="1.4"/><circle cx="1020" cy="415" r="1.4"/><circle cx="1020" cy="455" r="1.4"/><circle cx="1020" cy="495" r="1.4"/><circle cx="1020" cy="535" r="1.4"/><circle cx="1020" cy="575" r="1.4"/><circle cx="1020" cy="615" r="1.4"/><circle cx="1060" cy="15" r="1.4"/><circle cx="1060" cy="55" r="1.4"/><circle cx="1060" cy="95" r="1.4"/><circle cx="1060" cy="135" r="1.4"/><circle cx="1060" cy="175" r="1.4"/><circle cx="1060" cy="215" r="1.4"/><circle cx="1060" cy="255" r="1.4"/><circle cx="1060" cy="295" r="1.4"/><circle cx="1060" cy="335" r="1.4"/><circle cx="1060" cy="375" r="1.4"/><circle cx="1060" cy="415" r="1.4"/><circle cx="1060" cy="455" r="1.4"/><circle cx="1060" cy="495" r="1.4"/><circle cx="1060" cy="535" r="1.4"/><circle cx="1060" cy="575" r="1.4"/><circle cx="1060" cy="615" r="1.4"/><circle cx="1100" cy="15" r="1.4"/><circle cx="1100" cy="55" r="1.4"/><circle cx="1100" cy="95" r="1.4"/><circle cx="1100" cy="135" r="1.4"/><circle cx="1100" cy="175" r="1.4"/><circle cx="1100" cy="215" r="1.4"/><circle cx="1100" cy="255" r="1.4"/><circle cx="1100" cy="295" r="1.4"/><circle cx="1100" cy="335" r="1.4"/><circle cx="1100" cy="375" r="1.4"/><circle cx="1100" cy="415" r="1.4"/><circle cx="1100" cy="455" r="1.4"/><circle cx="1100" cy="495" r="1.4"/><circle cx="1100" cy="535" r="1.4"/><circle cx="1100" cy="575" r="1.4"/><circle cx="1100" cy="615" r="1.4"/><circle cx="1140" cy="15" r="1.4"/><circle cx="1140" cy="55" r="1.4"/><circle cx="1140" cy="95" r="1.4"/><circle cx="1140" cy="135" r="1.4"/><circle cx="1140" cy="175" r="1.4"/><circle cx="1140" cy="215" r="1.4"/><circle cx="1140" cy="255" r="1.4"/><circle cx="1140" cy="295" r="1.4"/><circle cx="1140" cy="335" r="1.4"/><circle cx="1140" cy="375" r="1.4"/><circle cx="1140" cy="415" r="1.4"/><circle cx="1140" cy="455" r="1.4"/><circle cx="1140" cy="495" r="1.4"/><circle cx="1140" cy="535" r="1.4"/><circle cx="1140" cy="575" r="1.4"/><circle cx="1140" cy="615" r="1.4"/><circle cx="1180" cy="15" r="1.4"/><circle cx="1180" cy="55" r="1.4"/><circle cx="1180" cy="95" r="1.4"/><circle cx="1180" cy="135" r="1.4"/><circle cx="1180" cy="175" r="1.4"/><circle cx="1180" cy="215" r="1.4"/><circle cx="1180" cy="255" r="1.4"/><circle cx="1180" cy="295" r="1.4"/><circle cx="1180" cy="335" r="1.4"/><circle cx="1180" cy="375" r="1.4"/><circle cx="1180" cy="415" r="1.4"/><circle cx="1180" cy="455" r="1.4"/><circle cx="1180" cy="495" r="1.4"/><circle cx="1180" cy="535" r="1.4"/><circle cx="1180" cy="575" r="1.4"/><circle cx="1180" cy="615" r="1.4"/></g><g shape-rendering="crispEdges"><rect x="472" y="172" width="16" height="16" fill="#ededef"/><rect x="492" y="172" width="16" height="16" fill="#ededef"/><rect x="512" y="172" width="16" height="16" fill="#ededef"/><rect x="452" y="192" width="16" height="16" fill="#ededef"/><rect x="532" y="192" width="16" height="16" fill="#ededef"/><rect x="452" y="212" width="16" height="16" fill="#ededef"/><rect x="452" y="232" width="16" height="16" fill="#ededef"/><rect x="492" y="232" width="16" height="16" fill="#ededef"/><rect x="512" y="232" width="16" height="16" fill="#ededef"/><rect x="532" y="232" width="16" height="16" fill="#ededef"/><rect x="452" y="252" width="16" height="16" fill="#ededef"/><rect x="532" y="252" width="16" height="16" fill="#ededef"/><rect x="452" y="272" width="16" height="16" fill="#ededef"/><rect x="532" y="272" width="16" height="16" fill="#ededef"/><rect x="472" y="292" width="16" height="16" fill="#ededef"/><rect x="492" y="292" width="16" height="16" fill="#ededef"/><rect x="512" y="292" width="16" height="16" fill="#ededef"/><rect x="612" y="172" width="16" height="16" fill="#b6a8ff"/><rect x="592" y="192" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="192" width="16" height="16" fill="#b6a8ff"/><rect x="572" y="212" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="212" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="232" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="252" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="272" width="16" height="16" fill="#b6a8ff"/><rect x="572" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="592" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="612" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="632" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="652" y="292" width="16" height="16" fill="#b6a8ff"/><rect x="652" y="172" width="16" height="16" fill="#ededef"/><rect x="672" y="172" width="16" height="16" fill="#ededef"/><rect x="692" y="172" width="16" height="16" fill="#ededef"/><rect x="712" y="172" width="16" height="16" fill="#ededef"/><rect x="732" y="172" width="16" height="16" fill="#ededef"/><rect x="692" y="192" width="16" height="16" fill="#ededef"/><rect x="692" y="212" width="16" height="16" fill="#ededef"/><rect x="692" y="232" width="16" height="16" fill="#ededef"/><rect x="692" y="252" width="16" height="16" fill="#ededef"/><rect x="692" y="272" width="16" height="16" fill="#ededef"/><rect x="692" y="292" width="16" height="16" fill="#ededef"/></g><text x="600" y="420" text-anchor="middle" font-family="Hanken Grotesk, system-ui, sans-serif" font-size="40" font-weight="500" fill="#a0a0a8" letter-spacing="-0.5">A git forge for teams of agents</text><text x="600" y="500" text-anchor="middle" font-family="IBM Plex Mono, ui-monospace, monospace" font-size="26" fill="#b6a8ff">g1t.sh</text></svg> |
| 12 | 12 | integrations: [ | |
| 13 | 13 | starlight({ | |
| 14 | 14 | title: 'g1t docs', | |
| 15 | − | description: 'Guides and reference for g1t, the git forge for teams of agents.', | |
| 15 | + | description: 'Guides and reference for g1t, where people and agents ship software together.', | |
| 16 | 16 | components: { | |
| 17 | 17 | Footer: './src/components/Footer.astro', | |
| 18 | 18 | Head: './src/components/Head.astro', | |
| 61 | 61 | { label: 'Quickstart', slug: 'quickstart' }, | |
| 62 | 62 | { label: 'How g1t works', slug: 'concepts/overview' }, | |
| 63 | 63 | { label: 'Search and Explore', slug: 'guides/search' }, | |
| 64 | + | { label: 'Status and incidents', slug: 'guides/status' }, | |
| 64 | 65 | ], | |
| 65 | 66 | }, | |
| 66 | 67 | { |
| 10 | 10 | }, | |
| 11 | 11 | "dependencies": { | |
| 12 | 12 | "@astrojs/starlight": "^0.42.5", | |
| 13 | + | "@g1t/contracts": "*", | |
| 13 | 14 | "@g1t/theme": "*", | |
| 14 | 15 | "@lucide/astro": "^1.52.0", | |
| 15 | 16 | "astro": "^7.2.10", |
| 3 | 3 | description: What g1t is for, and how issues, pull requests, checks, review, merging and sessions fit together. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | g1t is a git forge for teams of agents. You hand g1t an outcome, and a team | |
| 7 | − | of agents converges it onto `main`: each change is made in a pull request | |
| 8 | − | of its own, checked in a clean sandbox, reviewed, revised and merged under | |
| 9 | − | your repository's rules. People work alongside the agents in the same | |
| 10 | − | repositories, issues, pull requests and reviews. | |
| 6 | + | g1t is where people and agents ship software together. You hand g1t an | |
| 7 | + | outcome, and agents converge it onto `main`: each change is made in a pull | |
| 8 | + | request of its own, checked in a clean sandbox, reviewed, revised and merged | |
| 9 | + | under your repository's rules. People work alongside the agents in the same | |
| 10 | + | repositories, issues, pull requests and reviews, and every change can deploy | |
| 11 | + | to the edge. | |
| 11 | 12 | ||
| 12 | 13 | Underneath it is ordinary git: repositories, commits, branches, clone, push | |
| 13 | 14 | and pull all work as they do anywhere. On top of that it has the two things | |
| 14 | − | you already know from other forges, **issues** and **pull requests**, built | |
| 15 | + | you already know, **issues** and **pull requests**, built | |
| 15 | 16 | so that many agents can work at once without getting in each other's way. | |
| 16 | 17 | ||
| 17 | 18 | | | What it is | |
| 15 | 15 | design: it keeps passwords out of scripts and agents, and lets g1t protect | |
| 16 | 16 | the one place accounts are made. | |
| 17 | 17 | ||
| 18 | + | ## Your settings | |
| 19 | + | ||
| 20 | + | Your own settings are at [g1t.sh/settings](https://g1t.sh/settings), one | |
| 21 | + | page each. Open them from your account menu at the bottom of the sidebar, | |
| 22 | + | under **Your settings**; the sidebar then lists every page. | |
| 23 | + | ||
| 24 | + | | Page | Address | What is on it | | |
| 25 | + | | --- | --- | --- | | |
| 26 | + | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. | | |
| 27 | + | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 28 | + | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 29 | + | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/). | | |
| 30 | + | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens). | | |
| 31 | + | | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). | | |
| 32 | + | | Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. | | |
| 33 | + | | Security log | [`/settings/security-log`](https://g1t.sh/settings/security-log) | [What happened to your account](#security-log). | | |
| 34 | + | ||
| 35 | + | `g1t.sh/settings` opens Profile. | |
| 36 | + | ||
| 18 | 37 | ## Signing in with GitHub | |
| 19 | 38 | ||
| 20 | 39 | **Continue with GitHub** on the sign-in and sign-up pages signs you in with | |
| 21 | 40 | your GitHub account, and makes a g1t account the first time. Link or | |
| 22 | − | unlink GitHub in [Settings](https://g1t.sh/settings#github). See | |
| 41 | + | unlink GitHub in [Settings → GitHub](https://g1t.sh/settings/github). See | |
| 23 | 42 | [GitHub](/guides/github/#sign-in-with-github). | |
| 24 | 43 | ||
| 25 | 44 | Making an account with GitHub needs an invite too: start from your invite | |
| 36 | 55 | - when it was made for an email address, works only with that address; | |
| 37 | 56 | - can be revoked by whoever made it until it is used. | |
| 38 | 57 | ||
| 39 | − | An invite link, `g1t.sh/invite/<code>`, shows who sent it and continues to | |
| 40 | − | sign-up with the code filled in. | |
| 58 | + | ### Using an invite | |
| 59 | + | ||
| 60 | + | Every invite email links to `g1t.sh/invite/<code>`. That one page shows | |
| 61 | + | who sent it and what it is for (joining a workspace, collaborating on a | |
| 62 | + | repository, or just making an account), and finishes the job there: | |
| 63 | + | ||
| 64 | + | 1. **No account yet**: sign up on the page. When the invite was sent to | |
| 65 | + | your address, the email field is filled in and locked, and the address | |
| 66 | + | is confirmed already, so no confirmation email follows. Choose a | |
| 67 | + | username (one is suggested from your address) and a password, or select | |
| 68 | + | **Continue with GitHub**: the invite rides along, and the account uses | |
| 69 | + | the invited address when GitHub has verified it too. | |
| 70 | + | 2. **The address already has an account**: select **Sign in to accept**. | |
| 71 | + | After you sign in, the invite is accepted for you. | |
| 72 | + | 3. **Signed in as someone else**: an invite sent to one address works only | |
| 73 | + | for an account that has confirmed that address. The page says so and | |
| 74 | + | offers **Sign out and continue**. | |
| 41 | 75 | ||
| 76 | + | Once the account exists or you have signed in, you land in the workspace | |
| 77 | + | (or the repository) the invite was for, already a member, with a one-time | |
| 78 | + | "You're in" banner, and it becomes the workspace your sidebar shows. A | |
| 79 | + | code typed at [g1t.sh/register](https://g1t.sh/register) goes to the | |
| 80 | + | same page. | |
| 81 | + | ||
| 82 | + | An expired, revoked or used invite says which, and who sent it, so you | |
| 83 | + | can ask them for a new one; or ask for access from the same page. | |
| 84 | + | ||
| 42 | 85 | ### Making invites | |
| 43 | 86 | ||
| 44 | − | 1. Open [Settings → Invites](https://g1t.sh/settings#invites). | |
| 87 | + | 1. Open [Settings → Invites](https://g1t.sh/settings/invites). | |
| 45 | 88 | 2. Optionally enter the email address of the person you are inviting. | |
| 46 | 89 | With one, g1t emails them the invite, and only that address can use it. | |
| 47 | 90 | Without one, anyone with the link can, once. | |
| 67 | 110 | Write to [hey@flagon.io](mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20) | |
| 68 | 111 | with the subject `[g1t Invites]` and say who you would like to bring. g1t | |
| 69 | 112 | can give more invites to you, or to a workspace, whose owners then share | |
| 70 | − | them. Invites given to a workspace appear under **Settings → Invites** for | |
| 113 | + | them. Invites given to a workspace appear under | |
| 114 | + | [Settings → Invites](https://g1t.sh/settings/invites) for | |
| 71 | 115 | each of its owners, as a choice of whose invites to use. | |
| 72 | 116 | ||
| 73 | 117 | ### Asking for access | |
| 74 | 118 | ||
| 75 | 119 | Without an invite, [g1t.sh/register](https://g1t.sh/register) asks for your | |
| 76 | − | email address and, if you like, what you will build. g1t emails you an | |
| 77 | − | invite as places open. Asking again with the same address updates your | |
| 78 | − | request; it does not move you down the list. | |
| 120 | + | email address and, if you like, what you will build. g1t emails that | |
| 121 | + | address once to confirm you are on the list, and staff see the request | |
| 122 | + | straight away. When they approve it, the invite comes to the same address, | |
| 123 | + | sometimes with a note, and its link opens sign-up with the address filled | |
| 124 | + | in. There is no fixed date: g1t opens up a few people at a time. Asking | |
| 125 | + | again with the same address updates your request without another email; it | |
| 126 | + | does not move you down the list. | |
| 79 | 127 | ||
| 80 | 128 | ### Invites through the API | |
| 81 | 129 | ||
| 98 | 146 | ## Email addresses | |
| 99 | 147 | ||
| 100 | 148 | An account can have up to 10 email addresses. Manage them in | |
| 101 | − | [Settings → Emails](https://g1t.sh/settings#emails). | |
| 149 | + | [Settings → Emails](https://g1t.sh/settings/emails). | |
| 102 | 150 | ||
| 103 | 151 | | An address that is | Can | | |
| 104 | 152 | | --- | --- | | |
| 114 | 162 | ||
| 115 | 163 | ### Add an address | |
| 116 | 164 | ||
| 117 | − | 1. Open [Settings → Emails](https://g1t.sh/settings#emails). | |
| 165 | + | 1. Open [Settings → Emails](https://g1t.sh/settings/emails). | |
| 118 | 166 | 2. Enter the address under **Add an email address** and select **Add**. | |
| 119 | 167 | 3. Follow the link g1t sends it. The link works for 24 hours; **Resend | |
| 120 | 168 | link** sends a new one, at most once a minute and 10 times an hour. | |
| 169 | 217 | ``` | |
| 170 | 218 | remote: push declined: commit 3f9a1c2 would publish s***@gmail.com while your email is private. | |
| 171 | 219 | remote: Commit with 6c1d0efg+sam@users.noreply.g1t.sh (git config user.email 6c1d0efg+sam@users.noreply.g1t.sh) and amend, | |
| 172 | − | remote: or change this in g1t.sh/settings#emails. | |
| 220 | + | remote: or change this in g1t.sh/settings/emails. | |
| 173 | 221 | ``` | |
| 174 | 222 | ||
| 175 | 223 | To push those commits: | |
| 226 | 274 | | API | `Authorization: Bearer g1t_…` | | |
| 227 | 275 | | MCP | The same header, set when you add the server. | | |
| 228 | 276 | ||
| 229 | − | Create one in [Settings](https://g1t.sh/settings). A token is shown once, | |
| 277 | + | Create one in [Settings → Access tokens](https://g1t.sh/settings/tokens). A token is shown once, | |
| 230 | 278 | when it is created; g1t stores only a hash of it. If you lose one, delete it | |
| 231 | 279 | and create another. Delete a token the moment you think someone else has | |
| 232 | 280 | seen it. | |
| 245 | 293 | back, and you approve or deny. The application never sees your password and | |
| 246 | 294 | there is no token to copy. | |
| 247 | 295 | ||
| 248 | − | Applications you have approved are listed under **Connected applications** | |
| 249 | − | in [Settings](https://g1t.sh/settings). Signing one out ends its access at | |
| 296 | + | Applications you have approved are listed in | |
| 297 | + | [Settings → Connected applications](https://g1t.sh/settings/applications). Signing one out ends its access at | |
| 250 | 298 | once. | |
| 251 | 299 | ||
| 252 | 300 | For people building a client: | |
| 293 | 341 | ||
| 294 | 342 | The poll answers with a `status` of `pending`, `approved`, `denied` or | |
| 295 | 343 | `expired`. An approved answer carries the token, once. Codes expire after 15 | |
| 296 | − | minutes. The token appears in your settings under the tool's name, where you | |
| 344 | + | minutes. The token appears in | |
| 345 | + | [Settings → Access tokens](https://g1t.sh/settings/tokens) under the tool's name, where you | |
| 297 | 346 | can delete it. | |
| 298 | 347 | ||
| 299 | 348 | Only approve a code you asked for. Approving gives the tool the full rights | |
| 327 | 376 | ||
| 328 | 377 | ## Security log | |
| 329 | 378 | ||
| 330 | − | [Settings → Security log](https://g1t.sh/settings#security-log) lists what | |
| 379 | + | [Settings → Security log](https://g1t.sh/settings/security-log) lists what | |
| 331 | 380 | happened to your account: addresses added, confirmed, removed or made | |
| 332 | 381 | primary, your backup and privacy settings, password changes, and pauses | |
| 333 | 382 | after too many wrong passwords. Changes g1t staff made, such as removing an |
| 16 | 16 | <AgentSetup /> | |
| 17 | 17 | ||
| 18 | 18 | Signing in opens g1t in your browser; you approve, and the agent is | |
| 19 | − | connected. There is no token to copy. It shows up under **Connected | |
| 20 | − | applications** in [Settings](https://g1t.sh/settings), where you can sign | |
| 21 | − | it out. | |
| 19 | + | connected. There is no token to copy. It shows up in | |
| 20 | + | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 21 | + | where you can sign it out. | |
| 22 | 22 | ||
| 23 | 23 | The agent also needs to push with git, which asks for a username and a | |
| 24 | 24 | password: use your g1t username and an |
| 192 | 192 | **Issuing certificate** to **Active** by itself; **Check now** asks | |
| 193 | 193 | again at once. | |
| 194 | 194 | ||
| 195 | − | Custom domains need the workspace's g1t plan. Adding or removing one | |
| 196 | − | needs the Admin [role](/guides/access-and-roles/) on the project's repository. | |
| 195 | + | Custom domains need the workspace's g1t plan, and are $0.12 a month each | |
| 196 | + | (their cost plus 20%), as many as you like. Adding or removing one needs | |
| 197 | + | the Admin [role](/guides/access-and-roles/) on the project's repository. | |
| 197 | 198 | ||
| 198 | 199 | ### A subdomain, such as www | |
| 199 | 200 | ||
| 318 | 319 | ||
| 319 | 320 | ## What it costs | |
| 320 | 321 | ||
| 321 | − | Deployments are never free. They come with the | |
| 322 | + | Deployments come with the | |
| 322 | 323 | [g1t plan](/guides/usage-and-billing/#the-g1t-plan), $20 a month per | |
| 323 | − | workspace, which includes, each calendar month (UTC): | |
| 324 | + | workspace with $10 of usage included. There are **no quotas**: as many | |
| 325 | + | projects and previews as you like, and no count of builds, requests or | |
| 326 | + | domains ever stops or pauses an app. What deployments cost g1t is metered | |
| 327 | + | from the first unit, at Cloudflare's price plus 20%, and paid from the | |
| 328 | + | plan's $10 of included usage first; past it, it is charged up to your | |
| 329 | + | [spend limit](/guides/usage-and-billing/#limits). | |
| 324 | 330 | ||
| 325 | − | | Included | | | |
| 326 | − | | --- | --- | | |
| 327 | − | | 10 apps | The most apps up at once: production and previews together, across the workspace's projects. | | |
| 328 | − | | 1 million requests | To all of the workspace's apps. | | |
| 329 | − | | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. | | |
| 330 | − | | 3 custom domains | Across the workspace's projects, with their certificates. A www/apex pair is two. | | |
| 331 | − | | 200 build minutes | Every build's time, whether it succeeds or fails. | | |
| 332 | − | ||
| 333 | − | **Usage past that** is charged at Cloudflare's price plus 20%, paid from | |
| 334 | − | the plan's $10 of included usage first: | |
| 335 | − | ||
| 336 | − | | | Price | | |
| 337 | − | | --- | --- | | |
| 338 | − | | A build past the 200 minutes | $0.0015 a minute, by the second, whether it succeeds or fails. | | |
| 339 | − | | Each app past 10 | $0.024 a month | | |
| 340 | − | | Each million requests past 1 million | $0.36 | | |
| 341 | − | | Each million CPU milliseconds past 3 million | $0.024 | | |
| 342 | − | | Each custom domain past 3 | $0.12 a month, by the most the workspace had at once that month | | |
| 331 | + | | | Costs g1t | You pay | | |
| 332 | + | | --- | --- | --- | | |
| 333 | + | | Projects, previews and the apps behind them | Next to nothing | Not charged | | |
| 334 | + | | Builds, by the second, whether they succeed or fail | About $0.001 a minute | About $0.0012 a minute | | |
| 335 | + | | Requests, to all of the workspace's apps | $0.30 a million | $0.36 a million | | |
| 336 | + | | CPU time your code spends computing (waiting on the network is not counted) | $0.02 a million ms | $0.024 a million ms | | |
| 337 | + | | Custom domains, with their certificates (a www/apex pair is two) | $0.10 a month | $0.12 a month, by the most the workspace had at once that month | | |
| 343 | 338 | ||
| 344 | − | Builds are recorded when they finish: the plan's minutes pay for them | |
| 345 | − | first, and a build that runs past them is charged only for the seconds | |
| 346 | − | past them. Other usage past the allowance is charged once, on the first | |
| 347 | − | sweep after the month ends, as one line: *Deployments in 2026-10 past the | |
| 348 | − | plan*. | |
| 339 | + | Builds are charged when they finish. Requests, CPU time and custom domains | |
| 340 | + | are counted through the month, so your limit and **Billing** follow them as | |
| 341 | + | they happen, and charged once, on the first sweep after the month ends, as | |
| 342 | + | one line: *Deployments in 2026-10: 1,240,000 requests, 3,100,000 CPU ms, 2 | |
| 343 | + | custom domains*. | |
| 349 | 344 | ||
| 350 | 345 | A static site, or plain HTML, runs no code when it is visited: its files | |
| 351 | 346 | are served as they are, so serving it adds no requests or CPU time. Its | |
| 352 | 347 | builds are metered. A Workers project's code runs on every request, so its | |
| 353 | 348 | requests and CPU time are metered too. | |
| 354 | 349 | ||
| 355 | − | An app that no one visits costs nothing beyond counting toward the 10. That | |
| 356 | − | is why previews come down when their pull request closes and after their | |
| 357 | − | idle days. | |
| 350 | + | An app that no one visits costs nothing. Previews still come down when | |
| 351 | + | their pull request closes and after their idle days, to keep the list of | |
| 352 | + | what is up short. | |
| 353 | + | ||
| 354 | + | The only things that stop a workspace's apps are its own spend limit | |
| 355 | + | (apps pause with a notice, and come back once there is room) and the plan | |
| 356 | + | ending (they come down). | |
| 358 | 357 | ||
| 359 | 358 | Deployments used to be a $5 plan of their own. A Deployments subscription | |
| 360 | 359 | bought before keeps working until its current period ends, and is not | |
| 362 | 361 | ||
| 363 | 362 | ### Seeing what you use | |
| 364 | 363 | ||
| 365 | − | - **Billing**, under the plan, shows this month's apps, build | |
| 366 | − | minutes, requests and CPU time against what the plan includes. | |
| 367 | − | Requests and CPU time are counted from Cloudflare's analytics | |
| 368 | − | every 10 minutes. | |
| 364 | + | - **Billing**, under the plan, shows **This month's usage**: *Builds* | |
| 365 | + | (build minutes and their cost), *Requests & CPU* and *Custom domains*, | |
| 366 | + | in dollars, beside the rest of the workspace's usage. Requests and CPU | |
| 367 | + | time are counted from Cloudflare's analytics every 10 minutes. | |
| 369 | 368 | - The **statement** on Billing lists every build (*Building acme/web to | |
| 370 | − | production (48 s), all of it included in the plan*) and every month's | |
| 371 | − | usage past the plan. | |
| 369 | + | production (48 s), paid by your plan's included usage*) and every | |
| 370 | + | month's requests, CPU time and custom domains. | |
| 372 | 371 | - Each build's page shows how long it ran. | |
| 373 | 372 | ||
| 374 | 373 | ## Turn it off |
| 29 | 29 | ||
| 30 | 30 | Pushing, and reading private repositories, needs credentials. Use your | |
| 31 | 31 | username, and as the password either your account password or an | |
| 32 | − | [access token](https://g1t.sh/settings). Tokens are recommended: they can be revoked | |
| 32 | + | [access token](https://g1t.sh/settings/tokens). Tokens are recommended: they can be revoked | |
| 33 | 33 | individually and they also work for the API. | |
| 34 | 34 | ||
| 35 | 35 | To avoid typing it each time, let git store it: | |
| 60 | 60 | ||
| 61 | 61 | | You are | The page says | | |
| 62 | 62 | | --- | --- | | |
| 63 | − | | Signed out | **Sign in to see this project**: it may be private, or it may not exist. Signing in brings you back to the same address. | | |
| 64 | − | | Signed in | **This project doesn't exist, or you don't have access to it**, which account you are signed in as, and a link to switch account. If you should have access, ask someone with the Admin role on it to add you. | | |
| 63 | + | | Signed out | **Nothing here**: this page doesn't exist, or it's private; sign in if it's yours. **Sign in** brings you back to the same address. | | |
| 64 | + | | Signed in | **Nothing here**: this page doesn't exist, or you don't have access to it, with which account you are signed in as and a link to switch account. If you should have access, ask someone with the Admin role on it to add you. | | |
| 65 | 65 | ||
| 66 | − | Issues, pull requests and workspace pages work the same way. A profile | |
| 67 | − | that does not exist says **No one goes by that name**, since profiles are | |
| 68 | − | public. | |
| 66 | + | Issues, pull requests and workspace pages work the same way. The sidebar | |
| 67 | + | does not open the project or workspace the address names, so nothing on | |
| 68 | + | the page hints at whether it exists; a missing file, commit or issue in a | |
| 69 | + | project you can see keeps that project's sidebar. A profile that does not | |
| 70 | + | exist says **No one on g1t goes by that name**, since profiles are public. | |
| 69 | 71 | ||
| 70 | 72 | ## Browsing without an account | |
| 71 | 73 | ||
| 131 | 133 | Why: git over SSH needs raw TCP connections on port 22, and g1t runs | |
| 132 | 134 | entirely on Cloudflare's network. Accepting inbound TCP traffic directly | |
| 133 | 135 | into Workers is in a beta from Cloudflare that g1t has applied for and is | |
| 134 | − | waiting on. SSH keys can already be added under **Settings → SSH keys**, | |
| 136 | + | waiting on. SSH keys can already be added under | |
| 137 | + | [Settings → SSH keys](https://g1t.sh/settings/keys), | |
| 135 | 138 | and will be used once SSH is on. |
| 50 | 50 | ||
| 51 | 51 | ## Link and unlink GitHub | |
| 52 | 52 | ||
| 53 | − | In [Settings](https://g1t.sh/settings#github), under **GitHub**: | |
| 53 | + | In [Settings → GitHub](https://g1t.sh/settings/github): | |
| 54 | 54 | ||
| 55 | 55 | - **Link GitHub** sends you to GitHub and links the account you authorize. | |
| 56 | 56 | One GitHub account links to one g1t account, and the other way round. |
| 20 | 20 | | Repositories: create, push and clone over HTTP, browse code, commits | Works | | |
| 21 | 21 | | Issues, comments, labels | Works | | |
| 22 | 22 | | Site search | Works | | |
| 23 | + | | A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) | | |
| 23 | 24 | | Webhooks, integrations | Run, but scheduled retries do not (see below) | | |
| 24 | 25 | | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. | | |
| 25 | 26 | | g1t agents, plans, reviews by agents | Off | | |
| 33 | 34 | ||
| 34 | 35 | - Docker with Compose v2 (`docker compose version`). | |
| 35 | 36 | - About 4 GB of free disk space for the images. | |
| 36 | − | - Ports 8787 and 8025 free on your machine. | |
| 37 | + | - Ports 8787, 8788 and 8025 free on your machine. | |
| 37 | 38 | ||
| 38 | 39 | ## Start g1t | |
| 39 | 40 | ||
| 100 | 101 | | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through | | |
| 101 | 102 | | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. | | |
| 102 | 103 | | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` | | |
| 104 | + | | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. | | |
| 105 | + | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 106 | + | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 103 | 107 | | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. | | |
| 104 | 108 | ||
| 109 | + | ## The status page | |
| 110 | + | ||
| 111 | + | The `status` service runs the same status page as | |
| 112 | + | [status.g1t.sh](/guides/status/), in a process of its own, so it keeps | |
| 113 | + | answering when the site does not. Open | |
| 114 | + | [http://localhost:8788](http://localhost:8788). | |
| 115 | + | ||
| 116 | + | Every minute it loads the site's sign-in page from inside Compose, and, | |
| 117 | + | with `STATUS_PROBE_REPO` set, lists that repository's branches. It keeps | |
| 118 | + | 90 days of history on its own volume, `g1t-status`. Parts an installation | |
| 119 | + | of your own does not run (the API, MCP, docs, deployments, the model | |
| 120 | + | proxy and billing) are left off its page. | |
| 121 | + | ||
| 122 | + | The links to **Status** in the site's footer and account menu still point | |
| 123 | + | to status.g1t.sh; pointing them at your own status page is not a setting | |
| 124 | + | yet. | |
| 125 | + | ||
| 105 | 126 | To deliver email to real inboxes, have Mailpit relay it through your SMTP | |
| 106 | 127 | server. The settings are in `docker-compose.yml`, under `mailpit`. | |
| 107 | 128 |
| 1 | + | --- | |
| 2 | + | title: Status and incidents | |
| 3 | + | description: Where to see whether g1t is working, what each check measures, how incidents and maintenance are posted, how to subscribe, and the JSON and feeds behind the page. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | [status.g1t.sh](https://status.g1t.sh/) says whether each part of g1t is | |
| 7 | + | working now, how each has done over the last 90 days, what g1t staff have | |
| 8 | + | said about anything that went wrong, and what planned maintenance is | |
| 9 | + | coming. It runs as a service of its own, apart from the site, so it stays | |
| 10 | + | up when g1t.sh does not. | |
| 11 | + | ||
| 12 | + | The account menu at the bottom of the sidebar shows the same state as a | |
| 13 | + | dot beside **Status**, and so does the footer of g1t.sh's public pages. | |
| 14 | + | ||
| 15 | + | ## What is checked | |
| 16 | + | ||
| 17 | + | Every minute, each part gets one quick request, the same one a visitor | |
| 18 | + | would make, over the public internet: | |
| 19 | + | ||
| 20 | + | | Part | What the check does | | |
| 21 | + | | --- | --- | | |
| 22 | + | | Website and sign-in | Loads `g1t.sh/login`, and asks the API about an access token no one holds, which the account service must refuse with `401` | | |
| 23 | + | | API | Loads `api.g1t.sh/` | | |
| 24 | + | | Git and repositories | Lists the branches of a public repository over HTTPS (`info/refs`), the first step of every clone | | |
| 25 | + | | MCP server | Loads `mcp.g1t.sh/` | | |
| 26 | + | | Documentation | Loads `docs.g1t.sh/` | | |
| 27 | + | | Deployments | Loads `g1t.page/`. Each deployed app is not checked one by one. | | |
| 28 | + | | Agents' model proxy | Loads `models.g1t.sh/`. The model providers behind it are not checked. | | |
| 29 | + | | Sandboxes | Not checked yet: starting a sandbox costs money and takes seconds. The page says so rather than showing green. | | |
| 30 | + | | Billing | Reads billing's price book. Stripe itself is not checked. | | |
| 31 | + | ||
| 32 | + | A part that answers in over 1.5 seconds counts as slow, and one that | |
| 33 | + | fails or takes over 5 seconds counts as down. | |
| 34 | + | ||
| 35 | + | ## What each part shows | |
| 36 | + | ||
| 37 | + | A part shows the worse of its last check and what an open incident says | |
| 38 | + | about it. During planned maintenance it shows **Under maintenance** | |
| 39 | + | instead, unless an incident says something worse. | |
| 40 | + | ||
| 41 | + | | State | Meaning | | |
| 42 | + | | --- | --- | | |
| 43 | + | | Operational | Its check answered quickly, and no incident says otherwise | | |
| 44 | + | | Degraded | Slow to answer, or an incident reports degraded performance | | |
| 45 | + | | Partial outage | An incident reports that it is partly down | | |
| 46 | + | | Outage | Its check failed, or an incident reports a major outage | | |
| 47 | + | | Under maintenance | Planned work on it is under way | | |
| 48 | + | | Not monitored | It has no check, and no incident is open on it | | |
| 49 | + | ||
| 50 | + | The banner at the top sums them up: | |
| 51 | + | ||
| 52 | + | | Banner | When | | |
| 53 | + | | --- | --- | | |
| 54 | + | | All systems normal | Every checked part answered quickly, and no incident is open | | |
| 55 | + | | Under maintenance | Planned work is under way, and nothing else is wrong | | |
| 56 | + | | Degraded performance | Some parts are slow, and none is down | | |
| 57 | + | | Partial outage | A part other than the website, API or git is down, a part is partly down, or an incident is open | | |
| 58 | + | | Major outage | The website, the API or git is down, or an open incident reports a major outage | | |
| 59 | + | ||
| 60 | + | ## Uptime bars | |
| 61 | + | ||
| 62 | + | Each part has a bar of 90 days, one square per day in UTC, newest on the | |
| 63 | + | right. Hover over a square, tap it, or focus the bar and use the arrow | |
| 64 | + | keys to see that day's share of answered checks, failed and slow checks, | |
| 65 | + | the average response time, and any incident. On a phone the bar shows | |
| 66 | + | the last 30 days. | |
| 67 | + | ||
| 68 | + | | Square | The day | | |
| 69 | + | | --- | --- | | |
| 70 | + | | Green | At least 99.9% of checks answered, so one failed check in a day stays green | | |
| 71 | + | | Peach | At least 95% answered, over a quarter of answers were slow, or an incident with degraded performance or a partial outage on the part was open | | |
| 72 | + | | Red | Less than 95% answered, or an incident with a major outage on the part was open | | |
| 73 | + | | Gray | No checks yet | | |
| 74 | + | ||
| 75 | + | The percentage under each bar is the share of checks that answered over | |
| 76 | + | 90 days, rounded down, so a single failure never shows as 100%. Checks | |
| 77 | + | made while a part is under planned maintenance are not counted. | |
| 78 | + | ||
| 79 | + | ## Incidents | |
| 80 | + | ||
| 81 | + | When something goes wrong, g1t staff post an incident: a title, the | |
| 82 | + | parts it affects and how badly, and updates as it moves from | |
| 83 | + | **Investigating** to **Identified**, **Monitoring** and **Resolved**. | |
| 84 | + | ||
| 85 | + | - Open incidents are shown at the top under **Happening now**, with every | |
| 86 | + | update, newest first. | |
| 87 | + | - Each incident has a page of its own at | |
| 88 | + | `https://status.g1t.sh/incidents/<id>`, with its full history. Links in | |
| 89 | + | emails and feeds go there. | |
| 90 | + | - Incidents resolved in the last 14 days are listed under **Past | |
| 91 | + | incidents**; [History](https://status.g1t.sh/history) lists every | |
| 92 | + | incident and maintenance of the last 12 months, by month. | |
| 93 | + | - After a significant incident, g1t publishes a **postmortem** on the | |
| 94 | + | incident's page: a summary, the impact, a timeline, the root cause, what | |
| 95 | + | went well and badly, and what will change. Incidents with one are marked | |
| 96 | + | **Postmortem** in the lists. | |
| 97 | + | ||
| 98 | + | ### Severity | |
| 99 | + | ||
| 100 | + | Staff give every incident a severity. It decides how fast the team | |
| 101 | + | responds and how often it posts updates; it is not shown on the status | |
| 102 | + | page, which shows each part's impact instead. | |
| 103 | + | ||
| 104 | + | | Severity | Meaning | Public updates | | |
| 105 | + | | --- | --- | --- | | |
| 106 | + | | SEV1 | Critical: g1t is down or unusable for most people, or data is at risk | At least every 30 minutes; subscribers are emailed | | |
| 107 | + | | SEV2 | Major: a core part (sign-in, git, the API, agents) is broken or badly degraded for many people | At least hourly; subscribers are emailed | | |
| 108 | + | | SEV3 | Minor: one part is degraded or broken for some people, and there is a way around it | As things change | | |
| 109 | + | | SEV4 | Low: little or no customer impact | As things change | | |
| 110 | + | ||
| 111 | + | ### Noticed before anyone reports it | |
| 112 | + | ||
| 113 | + | When a part fails, or is slow, three checks in a row, g1t staff are | |
| 114 | + | alerted and an incident is drafted for them. It appears on the status | |
| 115 | + | page once someone confirms it, usually within minutes. The part's own | |
| 116 | + | state on the page changes at once either way, because it comes from the | |
| 117 | + | checks. | |
| 118 | + | ||
| 119 | + | If something is broken and the page does not show it, write to | |
| 120 | + | [hey@flagon.io](mailto:hey@flagon.io) or see | |
| 121 | + | [support](https://g1t.sh/support). | |
| 122 | + | ||
| 123 | + | ## Planned maintenance | |
| 124 | + | ||
| 125 | + | Work that may interrupt a part is announced ahead of time: | |
| 126 | + | ||
| 127 | + | 1. It is listed under **Upcoming maintenance** with its window (start and | |
| 128 | + | end, shown in your time zone), the parts it affects, and what you may | |
| 129 | + | notice. | |
| 130 | + | 2. When the window opens, it moves to **Maintenance in progress**, and its | |
| 131 | + | parts show **Under maintenance**. | |
| 132 | + | 3. When the window ends, it is marked complete on its own, with an update | |
| 133 | + | saying so. Staff can also start it early, finish it early, or cancel it. | |
| 134 | + | ||
| 135 | + | Each maintenance window has a page at | |
| 136 | + | `https://status.g1t.sh/maintenance/<id>`. | |
| 137 | + | ||
| 138 | + | ## Subscribe to updates | |
| 139 | + | ||
| 140 | + | Get an email when an incident or maintenance is posted or updated: | |
| 141 | + | ||
| 142 | + | 1. Enter your address under **Subscribe to updates** on | |
| 143 | + | [status.g1t.sh](https://status.g1t.sh/), or on | |
| 144 | + | [status.g1t.sh/subscribe](https://status.g1t.sh/subscribe) to choose | |
| 145 | + | which parts you hear about. Leave every part clear to hear about all of | |
| 146 | + | them. | |
| 147 | + | 2. Open the email from `noreply@g1t.sh` and follow **Confirm | |
| 148 | + | subscription**, then press the button on the page. The link works once, | |
| 149 | + | for 24 hours. Nothing is sent until you confirm. | |
| 150 | + | 3. To change which parts you hear about, subscribe again with the same | |
| 151 | + | address and confirm again. | |
| 152 | + | ||
| 153 | + | Every email has an **Unsubscribe** link at the bottom, and mail apps that | |
| 154 | + | support one-click unsubscribing show it too. Staff choose per update | |
| 155 | + | whether to email: SEV1 and SEV2 updates are emailed, smaller ones may | |
| 156 | + | not be. The feeds carry every update. | |
| 157 | + | ||
| 158 | + | ## Feeds | |
| 159 | + | ||
| 160 | + | Every public incident update and maintenance notice, newest first, the | |
| 161 | + | last 50: | |
| 162 | + | ||
| 163 | + | | Feed | Address | | |
| 164 | + | | --- | --- | | |
| 165 | + | | Atom (feed readers, Slack's RSS app, and most chat tools) | `https://status.g1t.sh/feed.xml` | | |
| 166 | + | | JSON Feed 1.1 | `https://status.g1t.sh/feed.json` | | |
| 167 | + | ||
| 168 | + | Each item links to the update on its incident's or maintenance's page. | |
| 169 | + | ||
| 170 | + | ## The status as JSON | |
| 171 | + | ||
| 172 | + | `GET https://status.g1t.sh/status.json` returns the same report, readable | |
| 173 | + | from any origin and cached for 30 seconds: | |
| 174 | + | ||
| 175 | + | ```sh | |
| 176 | + | curl -s https://status.g1t.sh/status.json | |
| 177 | + | ``` | |
| 178 | + | ||
| 179 | + | ```json | |
| 180 | + | { | |
| 181 | + | "checked_at": "2026-10-05T14:03:00.000Z", | |
| 182 | + | "overall": { | |
| 183 | + | "state": "up", | |
| 184 | + | "title": "All systems normal", | |
| 185 | + | "line": "Every part of g1t answered its last check." | |
| 186 | + | }, | |
| 187 | + | "components": [ | |
| 188 | + | { | |
| 189 | + | "key": "api", | |
| 190 | + | "name": "API", | |
| 191 | + | "address": "api.g1t.sh", | |
| 192 | + | "checks": "The API's front page.", | |
| 193 | + | "state": "up", | |
| 194 | + | "check_state": "up", | |
| 195 | + | "detail": "Answered in 84 ms", | |
| 196 | + | "latency_ms": 84, | |
| 197 | + | "uptime_90d": 99.98 | |
| 198 | + | } | |
| 199 | + | ], | |
| 200 | + | "incidents": [], | |
| 201 | + | "maintenance": [] | |
| 202 | + | } | |
| 203 | + | ``` | |
| 204 | + | ||
| 205 | + | | Field | What it is | | |
| 206 | + | | --- | --- | | |
| 207 | + | | `overall.state` | `up`, `degraded`, `down`, `maintenance`, or `unknown` before the first check | | |
| 208 | + | | `components[].state` | What the page shows: `up`, `degraded`, `partial`, `down`, `maintenance`, or `unmonitored` | | |
| 209 | + | | `components[].check_state` | What the last check alone said: `up`, `degraded`, `down` or `unmonitored` | | |
| 210 | + | | `components[].uptime_90d` | Share of checks answered over 90 days, 0 to 100, or `null` | | |
| 211 | + | | `incidents[]` | Open incidents, and those resolved in the last 90 days, newest first | | |
| 212 | + | | `incidents[].status` | `investigating`, `identified`, `monitoring` or `resolved` | | |
| 213 | + | | `incidents[].impact` | `down` when any part has a major outage, otherwise `degraded` | | |
| 214 | + | | `incidents[].component_impacts` | Each affected part's `key` and `impact`: `degraded`, `partial_outage` or `major_outage` | | |
| 215 | + | | `incidents[].url` | The incident's page | | |
| 216 | + | | `incidents[].postmortem_published_at` | When its postmortem was published, or `null` | | |
| 217 | + | | `incidents[].updates` | Newest first, each with `id`, `at`, `status` and `text` | | |
| 218 | + | | `maintenance[]` | Maintenance scheduled or under way, soonest first, each with `title`, `message`, `components`, `starts_at`, `ends_at`, `state` (`scheduled` or `in_progress`), `url` and `updates` | | |
| 219 | + | ||
| 220 | + | `https://status.g1t.sh/badge.svg` is a small badge with the banner's | |
| 221 | + | words, for a README or a dashboard. | |
| 222 | + | ||
| 223 | + | The old addresses, `g1t.sh/status` and `g1t.sh/status.json`, redirect | |
| 224 | + | here. |
| 110 | 110 | ## Deployments | |
| 111 | 111 | ||
| 112 | 112 | A `g1t.page` address ends in its workspace's name: production is at | |
| 113 | − | `<project>-<workspace>.g1t.page`. After a transfer, each app that is up is | |
| 114 | − | built again from the same commit under the new workspace's name. The old | |
| 115 | − | address keeps serving until the new one is live, then redirects to it for | |
| 116 | − | 90 days. Custom domains move with the project and serve the new build. | |
| 113 | + | `<project>-<workspace>.g1t.page`. After a transfer, each of the project's | |
| 114 | + | apps (production and every open preview, including one paused by the old | |
| 115 | + | workspace's usage limit) is built again from the same commit under the new | |
| 116 | + | workspace's name. The old address keeps serving until the new one is live, | |
| 117 | + | then redirects to it for 90 days, whatever the old workspace's plan or | |
| 118 | + | limit. Custom domains move with the project and serve the new build. | |
| 119 | + | ||
| 120 | + | From the transfer on, the apps are the new workspace's: its plan and usage | |
| 121 | + | limit decide whether they build and serve. If the new workspace cannot | |
| 122 | + | build yet (Deployments are off, or it reached its limit), the rebuild waits | |
| 123 | + | and is tried again until it can. | |
| 117 | 124 | ||
| 118 | 125 | ## Billing | |
| 119 | 126 |
| 6 | 6 | The forge is free: repositories, git, issues, pull requests and review cost | |
| 7 | 7 | nothing, for public and private work. What costs money is compute, the | |
| 8 | 8 | things g1t runs for you: agents, the sandboxes they and your checks run in, | |
| 9 | − | deployed apps, and, on the plan, storage and git traffic past what is | |
| 10 | − | included. Each is | |
| 11 | − | charged at what it costs g1t plus 20%, to the workspace that owns the | |
| 12 | − | repository. There is no seat price. | |
| 9 | + | deployed apps, and, on the plan, storage and git traffic past the free | |
| 10 | + | amounts. Each is charged at what it costs g1t plus 20%, to the workspace | |
| 11 | + | that owns the repository. There is no seat price, and on the plan there | |
| 12 | + | are no quotas: only your spend limit stops anything. | |
| 13 | 13 | ||
| 14 | 14 | ## What is free | |
| 15 | 15 | ||
| 20 | 20 | - Search and Explore. | |
| 21 | 21 | - The [audit log](/guides/audit-log/), kept 90 days, with export. | |
| 22 | 22 | - Your own agent through [MCP](/reference/mcp/). | |
| 23 | − | - 1 GB of private repository storage, and 10,000 | |
| 23 | + | - 1 GB of private repository storage, and 50,000 | |
| 24 | 24 | [git operations](#git-operations) a month. | |
| 25 | 25 | ||
| 26 | − | None of this is charged. A free workspace is never charged for storage: | |
| 27 | − | once its private repositories hold 1 GB, pushes to them are refused at the | |
| 28 | − | start of the push, with what to do: make the repository public, delete what | |
| 29 | − | you do not need, or start the plan for 10 GB. Public repositories are never | |
| 30 | − | charged for storage. | |
| 26 | + | None of this is charged, on the plan or not. A free workspace is never | |
| 27 | + | charged for storage: once its private repositories hold 1 GB, pushes to | |
| 28 | + | them are refused at the start of the push, with what to do: make the | |
| 29 | + | repository public, delete what you do not need, or start the plan, where | |
| 30 | + | storage past 1 GB is usage and pushes never stop. Public repositories are | |
| 31 | + | never charged for storage. | |
| 31 | 32 | ||
| 32 | 33 | Compute is not free. Agents, checks, workflows, the merge queue, | |
| 33 | 34 | deployments and semantic search need [the g1t plan](#the-g1t-plan), or a | |
| 38 | 39 | One plan, **$20 a month per workspace**, however many people and agents | |
| 39 | 40 | are in it. It is never priced per person. | |
| 40 | 41 | ||
| 41 | − | | Each month (UTC) | | | |
| 42 | − | | --- | --- | | |
| 43 | − | | Included usage | $10, at cost plus 20% | | |
| 44 | − | | Members | Unlimited | | |
| 45 | − | | Private repository storage | 10 GB | | |
| 46 | − | | Git operations | 10,000 | | |
| 47 | − | | [Deployments](/guides/deployments/) | 10 apps, 1 million requests, 3 million CPU milliseconds, 3 custom domains and 200 build minutes | | |
| 42 | + | - **$10 of usage each month** at cost plus 20%, used first. | |
| 43 | + | - **Everyone in the workspace** at one price, never per person. | |
| 44 | + | - **Unlimited** projects, previews and repositories. | |
| 45 | + | - Agents, checks, workflows, the merge queue, | |
| 46 | + | [deployments](/guides/deployments/) and semantic search. | |
| 47 | + | - Usage past $10 is charged at cost plus 20%, **up to your | |
| 48 | + | [spend limit](#limits)**. | |
| 49 | + | ||
| 50 | + | There are **no quotas** on the plan: no count of projects, previews, | |
| 51 | + | build minutes, requests, custom domains, git operations or gigabytes | |
| 52 | + | stops a workspace. Everything that costs g1t money is metered from the | |
| 53 | + | first unit (past the free 1 GB of private storage and 50,000 git | |
| 54 | + | operations that every workspace has), and only your spend limit stops | |
| 55 | + | work. | |
| 48 | 56 | ||
| 49 | 57 | **Included usage** pays for the month's usage first, at the same prices as | |
| 50 | − | everything else: agents, sandbox time, builds, storage, git operations, | |
| 51 | − | search embeddings and security scans. Past it, usage is charged at cost | |
| 52 | − | plus 20%. It starts again on the 1st of each month (UTC). **Unused | |
| 53 | − | included usage does not roll over.** Billing shows how much of it this | |
| 54 | − | month's usage has drawn. | |
| 58 | + | everything else: agents, sandbox time, builds, app traffic, custom domains, | |
| 59 | + | storage, git operations, search embeddings and security scans. Past it, | |
| 60 | + | usage is charged at cost plus 20%. It starts again on the 1st of each | |
| 61 | + | month (UTC). **Unused included usage does not roll over.** Billing shows | |
| 62 | + | how much of it this month's usage has drawn. | |
| 63 | + | ||
| 64 | + | ### What it costs | |
| 65 | + | ||
| 66 | + | Every price is what g1t pays plus 20%. The live figures are on | |
| 67 | + | [g1t.sh/pricing](https://g1t.sh/pricing). | |
| 55 | 68 | ||
| 69 | + | | What | Unit | Costs g1t | You pay | | |
| 70 | + | | --- | --- | --- | --- | | |
| 71 | + | | Models | A run | What the provider charged | Cost + 20% | | |
| 72 | + | | Sandbox time (agents, checks, workflows, the merge queue) | Second | About $0.000016 | About $0.000019 | | |
| 73 | + | | Deploy builds | Second | About $0.000016 | About $0.000019 ($0.0012 a minute) | | |
| 74 | + | | App requests | Million | $0.30 | $0.36 | | |
| 75 | + | | App CPU time | Million CPU milliseconds | $0.02 | $0.024 | | |
| 76 | + | | Custom domains | Domain-month | $0.10 | $0.12 | | |
| 77 | + | | Private storage, past the free 1 GB | GB-month | $0.50 | $0.60 | | |
| 78 | + | | Git operations, past the free 50,000 a month | 1,000 | $0.15 | $0.18 | | |
| 79 | + | | Search embeddings | Million tokens | $0.067 | $0.0804 | | |
| 80 | + | | Security scans | Million CPU ms / million rows | $0.02 / $1.00 | $0.024 / $1.20 | | |
| 81 | + | | Projects, previews, apps and repositories | | Next to nothing | Not charged | | |
| 82 | + | ||
| 83 | + | Sandbox and build seconds follow what Cloudflare bills g1t, so the | |
| 84 | + | figures above move a little; the pricing page always has today's. | |
| 85 | + | ||
| 56 | 86 | **The other $10** pays for running g1t, the free forge everyone uses, and | |
| 57 | 87 | the people building it. | |
| 58 | 88 | ||
| 70 | 100 | ||
| 71 | 101 | Back on Billing, the card says **On the g1t plan** (**first month** in | |
| 72 | 102 | the first billing cycle), with a meter for the month's included usage and | |
| 73 | − | one for deployments. **Manage on Stripe** opens the card, invoices and | |
| 103 | + | **This month's usage**: what each kind of usage has come to so far, in | |
| 104 | + | dollars and in what was used (*Agents & sandboxes*, *Builds*, *Requests & | |
| 105 | + | CPU*, *Custom domains*, *Git operations & storage*, *Search & security | |
| 106 | + | scans*). App traffic, custom domains, storage and git operations are | |
| 107 | + | counted through the month and charged when it closes. **Manage on Stripe** opens the card, invoices and | |
| 74 | 108 | billing details. **End at the end of the period** ends the plan then, with | |
| 75 | 109 | nothing more charged after; **Keep the plan** takes that back until then. | |
| 76 | 110 | If a renewal payment fails, the card says **Payment failed**, with | |
| 155 | 189 | | Acceptance checks | [Sandbox time](#sandbox-time) | | |
| 156 | 190 | | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) | | |
| 157 | 191 | | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time) | | |
| 158 | − | | [Deployments](/guides/deployments/) | Past what the plan includes | | |
| 159 | − | | [Private repository storage](#storage-search-embeddings-and-scans) | Past 10 GB, on the plan only | | |
| 160 | − | | [Git operations](#git-operations) | Past 10,000 a month, on the plan only | | |
| 192 | + | | [Deployments](/guides/deployments/) | Builds, requests, CPU time and custom domains, from the first. Projects and previews are not charged. | | |
| 193 | + | | [Private repository storage](#storage-search-embeddings-and-scans) | Past the free 1 GB, on the plan only | | |
| 194 | + | | [Git operations](#git-operations) | Past the free 50,000 a month, on the plan only | | |
| 161 | 195 | | [Search embeddings](#storage-search-embeddings-and-scans) | For private text | | |
| 162 | 196 | | [Security scans](#storage-search-embeddings-and-scans) | Yes | | |
| 163 | 197 | | Repositories, issues, pull requests, review, search, the API and MCP | No | | |
| 185 | 219 | | Models | What the provider charged for the run | | |
| 186 | 220 | | Sandbox time | Per second: memory, disk and the Durable Object behind each container, plus CPU | | |
| 187 | 221 | | Deploy builds | Per second | | |
| 188 | − | | App requests, CPU and apps | Per million requests, per million CPU milliseconds, per app-month | | |
| 189 | − | | Custom domains past the plan | Per domain-month | | |
| 190 | − | | Private storage | Per GB-month | | |
| 191 | − | | Git operations | Per 1,000 | | |
| 222 | + | | App requests and CPU | Per million requests, per million CPU milliseconds | | |
| 223 | + | | Custom domains | Per domain-month | | |
| 224 | + | | Private storage past the free 1 GB | Per GB-month | | |
| 225 | + | | Git operations past the free 50,000 a month | Per 1,000 | | |
| 192 | 226 | | Search embeddings | Per million tokens | | |
| 193 | 227 | | Security scans | Per million CPU milliseconds and per million rows written | | |
| 194 | 228 | ||
| 226 | 260 | Each sandbox is one line on the [statement](#the-statement), such as | |
| 227 | 261 | *Checks on acme/api#12: 3m 12s of sandbox time*. Deploy builds are not | |
| 228 | 262 | counted here: [Deployments](/guides/deployments/) charges them by the | |
| 229 | − | second, after the plan's 200 build minutes. | |
| 263 | + | second, from the first, under **Builds**. | |
| 230 | 264 | ||
| 231 | 265 | ## Git operations | |
| 232 | 266 | ||
| 236 | 270 | ||
| 237 | 271 | | | Each month (UTC) | | |
| 238 | 272 | | --- | --- | | |
| 239 | − | | Included, on every workspace | 10,000 | | |
| 240 | − | | On the plan, past 10,000 | $0.18 per 1,000 (cost plus 20%) | | |
| 273 | + | | Free, on every workspace | 50,000 | | |
| 274 | + | | On the plan, past 50,000 | $0.18 per 1,000 (cost plus 20%). Never slowed or refused. | | |
| 241 | 275 | | Free workspace, past 50,000 | Not charged. Slowed to 60 an hour until the month turns. | | |
| 242 | 276 | ||
| 243 | − | 10,000 is about twenty times what an active workspace uses; an agent run | |
| 244 | − | takes two to four. A free workspace is never charged for git operations. | |
| 245 | − | Past 50,000 in a month, its git requests past 60 in an hour are answered | |
| 246 | − | `429` with when to try again. | |
| 277 | + | 50,000 is far more than an active workspace uses; an agent run takes two | |
| 278 | + | to four. A workspace on the plan pushes and clones on past it and pays for | |
| 279 | + | what it uses, up to its spend limit. A free workspace is never charged for | |
| 280 | + | git operations: past 50,000 in a month, its git requests past 60 in an | |
| 281 | + | hour are answered `429` with when to try again. | |
| 247 | 282 | ||
| 248 | − | Counting starts on 2026-10-14. **Billing** shows this month's count | |
| 249 | − | against what is included. | |
| 283 | + | Counting starts on 2026-10-14. **Billing** shows this month's count under | |
| 284 | + | **Git operations & storage**. | |
| 250 | 285 | ||
| 251 | 286 | ## Storage, search embeddings and scans | |
| 252 | 287 | ||
| 256 | 291 | ||
| 257 | 292 | | | What it costs g1t | What is counted | | |
| 258 | 293 | | --- | --- | --- | | |
| 259 | − | | Private repository storage | $0.50 a GB-month (Cloudflare Artifacts) | On the plan, each day, what the workspace's private repositories hold past 10 GB. A month's GB-months are those days added up, divided by 30. | | |
| 294 | + | | Private repository storage | $0.50 a GB-month (Cloudflare Artifacts) | On the plan, each day, what the workspace's private repositories hold past the free 1 GB. A month's GB-months are those days added up, divided by 30. | | |
| 260 | 295 | | Search embeddings | $0.067 per million tokens (Workers AI) | The text of private repositories, issues and pull requests put in the search index. Public text and searches are not charged. | | |
| 261 | 296 | | Security scans | $0.02 per million CPU milliseconds and $1.00 per million rows written (Workers and D1) | The CPU each history scan and dependency check takes and the rows it writes. | | |
| 262 | 297 | ||
| 264 | 299 | the packs pushed through g1t's git endpoints to each repository and its | |
| 265 | 300 | pull requests' working copies, so what is charged is never more than what | |
| 266 | 301 | is stored. Public repositories are never charged, and neither is a free | |
| 267 | − | workspace: its pushes to private repositories stop at 1 GB instead. | |
| 302 | + | workspace: its pushes to private repositories stop at 1 GB instead. On the | |
| 303 | + | plan they never stop. | |
| 268 | 304 | ||
| 269 | 305 | ## Limits | |
| 270 | 306 | ||
| 542 | 578 | | Agent runs | Runs on g1t's models: the model's cost plus the margin. | | |
| 543 | 579 | | Runs on your own model provider | Older months only: the flat fee runs on your own provider used to carry. | | |
| 544 | 580 | | Sandbox time | Each sandbox's time. | | |
| 545 | − | | Deployments | Builds and apps past what the plan includes. | | |
| 546 | − | | Private storage | Storage past what is included, once a month. | | |
| 547 | − | | Git operations | Operations past what is included, once a month. | | |
| 581 | + | | Deployments | Builds as they finish; each month's requests, CPU time and custom domains when it closes. | | |
| 582 | + | | Private storage | Storage past the free 1 GB, once a month. | | |
| 583 | + | | Git operations | Operations past the free 50,000, once a month. | | |
| 548 | 584 | | Search embeddings | Private text put in the search index, once a month. | | |
| 549 | 585 | | Security scans | History scans and dependency checks, once a month. | | |
| 550 | 586 | | Payments | Card payments and invoices paid. | | |
| 597 | 633 | `maxConcurrentAgents`, `maxRunMinutes`, `runCapMicros`, `issueCapMicros`, | |
| 598 | 634 | `ceilingMicros`, `exposureMicros`, `heldMicros`, `prepaidMicros`, | |
| 599 | 635 | `includedMicros`, `includedUsedMicros`, `paused` (why compute is paused, | |
| 600 | − | or none), `spike`, `alerts`, `gitOperations` and `gitOperationsIncluded`. | |
| 636 | + | or none), `spike`, `alerts`, `gitOperations` and `gitOperationsIncluded` | |
| 637 | + | (the git operations free for every workspace), `freePrivateStorageBytes` | |
| 638 | + | (the private storage free for every workspace) and `buildSecondsUsed`. | |
| 639 | + | None of these is a quota on the plan. | |
| 640 | + | ||
| 641 | + | **`usage_meters`** (`workspace`, `viewer`; members only) returns this | |
| 642 | + | month's usage in six lines, `agents`, `builds`, `requests`, `domains`, | |
| 643 | + | `git_storage` and `search_scans`, each with `micros` (at cost plus 20%, | |
| 644 | + | before included usage or a pool paid for it) and a `quantity` such as | |
| 645 | + | *42 build minutes*. It is what **This month's usage** on Billing shows. | |
| 601 | 646 | ||
| 602 | 647 | **`reserve`** holds the work's estimated cost before it starts, so starts | |
| 603 | 648 | at the same moment cannot overshoot together. `kind` is `agent`, `check`, |
| 65 | 65 | that changes gets a new address and nothing shows the old one. | |
| 66 | 66 | ||
| 67 | 67 | You can upload a picture of yourself the same way, under | |
| 68 | − | [Settings → Picture](https://g1t.sh/settings#picture). | |
| 68 | + | [Settings → Profile](https://g1t.sh/settings/profile). | |
| 69 | 69 | ||
| 70 | 70 | ## Rename a workspace | |
| 71 | 71 | ||
| 214 | 214 | an account, it costs nothing, and they join when they accept. The page | |
| 215 | 215 | never says which it was. | |
| 216 | 216 | ||
| 217 | + | The email names you and the workspace and links to the invite's page. | |
| 218 | + | Someone new signs up right there, with the invited address filled in and | |
| 219 | + | already confirmed; someone with an account signs in. Either way they land | |
| 220 | + | in the workspace as a member, with a one-time welcome. See | |
| 221 | + | [using an invite](/guides/authentication/#using-an-invite). | |
| 222 | + | ||
| 217 | 223 | Pending invites are listed under the members, with a link to copy and | |
| 218 | 224 | **Revoke**. An owner can also remove a member there. Through the API, use | |
| 219 | 225 | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | |
| 276 | 282 | | Can reach | Every workspace you belong to | That workspace only | | |
| 277 | 283 | | Can do | Everything you can | Admin on the workspace's repositories; it cannot manage people, tokens or workspaces | | |
| 278 | 284 | | When its creator leaves | Stops working | Keeps working | | |
| 279 | − | | Created by | You, in [Settings](https://g1t.sh/settings) | An owner, under the workspace's **Settings → Access tokens** | | |
| 285 | + | | Created by | You, in [Settings → Access tokens](https://g1t.sh/settings/tokens) | An owner, under the workspace's **Settings → Access tokens** | | |
| 280 | 286 | ||
| 281 | 287 | They are the same kind of token and are sent the same way; see | |
| 282 | 288 | [access tokens](/guides/authentication/#access-tokens). With git, any | |
| 302 | 308 | repository and sort order. Add `?tab=pulls&state=merged` and the like to | |
| 303 | 309 | link to a filtered list. | |
| 304 | 310 | ||
| 305 | − | **Edit it** under **Profile** in [Settings](https://g1t.sh/settings). Every | |
| 311 | + | **Edit it** in [Settings → Profile](https://g1t.sh/settings/profile). Every | |
| 306 | 312 | field is optional. The bio takes up to 160 characters and is also what a | |
| 307 | 313 | link to your profile says. The website must be an `https://` address; | |
| 308 | 314 | `example.com` is saved as `https://example.com`. Your email address is |
| 30 | 30 | 2. **Create an access token** | |
| 31 | 31 | ||
| 32 | 32 | Git and the API authenticate with a token. Create one in | |
| 33 | − | [Settings](https://g1t.sh/settings) and copy it: it is shown once. | |
| 33 | + | [Settings → Access tokens](https://g1t.sh/settings/tokens) and copy it: it is shown once. | |
| 34 | 34 | ||
| 35 | 35 | ```sh | |
| 36 | 36 | export G1T_TOKEN=g1t_… |
| 40 | 40 | ## Authentication | |
| 41 | 41 | ||
| 42 | 42 | A token is needed to change anything, and to see what is private. Send an | |
| 43 | − | [access token](https://g1t.sh/settings) as a bearer token: | |
| 43 | + | [access token](https://g1t.sh/settings/tokens) as a bearer token: | |
| 44 | 44 | ||
| 45 | 45 | ```sh | |
| 46 | 46 | curl https://api.g1t.sh/user \ |
| 1 | 1 | import { defineRouteMiddleware } from '@astrojs/starlight/route-data'; | |
| 2 | + | import { ogVersion } from '@g1t/contracts/og'; | |
| 2 | 3 | ||
| 3 | 4 | import { tabFor } from './tabs'; | |
| 4 | 5 | ||
| 56 | 57 | card.searchParams.set('title', title); | |
| 57 | 58 | if (section) card.searchParams.set('section', section); | |
| 58 | 59 | if (description) card.searchParams.set('description', description); | |
| 60 | + | // The render version, so a new card design is a new address for every cache. | |
| 61 | + | card.searchParams.set('v', ogVersion()); | |
| 59 | 62 | const image = card.toString(); | |
| 60 | 63 | const alt = `${title} · g1t docs`; | |
| 61 | 64 | const meta = (key: 'property' | 'name', name: string, content: string) => ({ |
| 1 | + | -- The status page's own record: each part's last check, a day-by-day | |
| 2 | + | -- tally for the 90-day bars, and incidents posted from sudo. | |
| 3 | + | ||
| 4 | + | -- Each part at its last check. | |
| 5 | + | CREATE TABLE current ( | |
| 6 | + | component TEXT PRIMARY KEY, | |
| 7 | + | state TEXT NOT NULL, | |
| 8 | + | detail TEXT NOT NULL, | |
| 9 | + | latency_ms INTEGER, | |
| 10 | + | checked_at TEXT NOT NULL | |
| 11 | + | ); | |
| 12 | + | ||
| 13 | + | -- One row per part per UTC day: how many checks, and how they went. | |
| 14 | + | -- Rows older than 90 days are deleted as checks run. | |
| 15 | + | CREATE TABLE daily ( | |
| 16 | + | component TEXT NOT NULL, | |
| 17 | + | day TEXT NOT NULL, | |
| 18 | + | checks INTEGER NOT NULL DEFAULT 0, | |
| 19 | + | up INTEGER NOT NULL DEFAULT 0, | |
| 20 | + | degraded INTEGER NOT NULL DEFAULT 0, | |
| 21 | + | down INTEGER NOT NULL DEFAULT 0, | |
| 22 | + | latency_total INTEGER NOT NULL DEFAULT 0, | |
| 23 | + | latency_count INTEGER NOT NULL DEFAULT 0, | |
| 24 | + | PRIMARY KEY (component, day) | |
| 25 | + | ); | |
| 26 | + | CREATE INDEX daily_day ON daily (day); | |
| 27 | + | ||
| 28 | + | -- When the parts were last checked, as a whole. | |
| 29 | + | CREATE TABLE meta ( | |
| 30 | + | key TEXT PRIMARY KEY, | |
| 31 | + | value TEXT NOT NULL | |
| 32 | + | ); | |
| 33 | + | ||
| 34 | + | CREATE TABLE incidents ( | |
| 35 | + | id TEXT PRIMARY KEY, | |
| 36 | + | title TEXT NOT NULL, | |
| 37 | + | impact TEXT NOT NULL CHECK (impact IN ('degraded', 'down')), | |
| 38 | + | status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')), | |
| 39 | + | -- A JSON array of component keys. | |
| 40 | + | components TEXT NOT NULL, | |
| 41 | + | started_at TEXT NOT NULL, | |
| 42 | + | resolved_at TEXT, | |
| 43 | + | created_by TEXT NOT NULL | |
| 44 | + | ); | |
| 45 | + | CREATE INDEX incidents_started ON incidents (started_at); | |
| 46 | + | ||
| 47 | + | CREATE TABLE incident_updates ( | |
| 48 | + | id TEXT PRIMARY KEY, | |
| 49 | + | incident_id TEXT NOT NULL REFERENCES incidents (id), | |
| 50 | + | status TEXT NOT NULL, | |
| 51 | + | message TEXT NOT NULL, | |
| 52 | + | at TEXT NOT NULL, | |
| 53 | + | by TEXT NOT NULL | |
| 54 | + | ); | |
| 55 | + | CREATE INDEX incident_updates_incident ON incident_updates (incident_id, at); |
| 1 | + | -- Incident management: severity, a lifecycle with timestamps, each part's | |
| 2 | + | -- impact, roles, an internal timeline beside the public updates, | |
| 3 | + | -- follow-ups, postmortems, scheduled maintenance, email subscribers, | |
| 4 | + | -- detection streaks, and an audit log of every staff change. | |
| 5 | + | -- | |
| 6 | + | -- Idempotent: every table and index is made only if missing, and the | |
| 7 | + | -- incidents posted before this (the `incidents` and `incident_updates` | |
| 8 | + | -- tables of 0001) are copied in with INSERT OR IGNORE, so running it | |
| 9 | + | -- again changes nothing. The 0001 tables are left in place, unused, and | |
| 10 | + | -- can be dropped by a later migration. | |
| 11 | + | ||
| 12 | + | CREATE TABLE IF NOT EXISTS incident ( | |
| 13 | + | id TEXT PRIMARY KEY, | |
| 14 | + | title TEXT NOT NULL, | |
| 15 | + | severity TEXT NOT NULL CHECK (severity IN ('sev1', 'sev2', 'sev3', 'sev4')), | |
| 16 | + | status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')), | |
| 17 | + | -- draft: not on the status page yet (detected, or saved); dismissed: a | |
| 18 | + | -- draft closed as a false alarm, never shown. | |
| 19 | + | visibility TEXT NOT NULL CHECK (visibility IN ('draft', 'public', 'dismissed')), | |
| 20 | + | source TEXT NOT NULL CHECK (source IN ('declared', 'detected')), | |
| 21 | + | -- When the impact began; declared_at is when staff (or the checks) said so. | |
| 22 | + | started_at TEXT NOT NULL, | |
| 23 | + | declared_at TEXT NOT NULL, | |
| 24 | + | acknowledged_at TEXT, | |
| 25 | + | mitigated_at TEXT, | |
| 26 | + | resolved_at TEXT, | |
| 27 | + | published_at TEXT, | |
| 28 | + | commander TEXT, | |
| 29 | + | communications TEXT, | |
| 30 | + | created_by TEXT NOT NULL | |
| 31 | + | ); | |
| 32 | + | CREATE INDEX IF NOT EXISTS incident_started ON incident (started_at); | |
| 33 | + | CREATE INDEX IF NOT EXISTS incident_open ON incident (resolved_at, visibility); | |
| 34 | + | ||
| 35 | + | -- What an incident does to each part, while it is open. | |
| 36 | + | CREATE TABLE IF NOT EXISTS incident_component ( | |
| 37 | + | incident_id TEXT NOT NULL, | |
| 38 | + | component TEXT NOT NULL, | |
| 39 | + | impact TEXT NOT NULL CHECK (impact IN ('operational', 'degraded', 'partial_outage', 'major_outage')), | |
| 40 | + | PRIMARY KEY (incident_id, component) | |
| 41 | + | ); | |
| 42 | + | ||
| 43 | + | -- Everything that happened, in order: public updates (public = 1, what | |
| 44 | + | -- the status page shows) and, for staff only, notes and every change. | |
| 45 | + | CREATE TABLE IF NOT EXISTS incident_timeline ( | |
| 46 | + | id TEXT PRIMARY KEY, | |
| 47 | + | incident_id TEXT NOT NULL, | |
| 48 | + | at TEXT NOT NULL, | |
| 49 | + | by TEXT NOT NULL, | |
| 50 | + | kind TEXT NOT NULL, | |
| 51 | + | public INTEGER NOT NULL DEFAULT 0, | |
| 52 | + | status TEXT, | |
| 53 | + | text TEXT NOT NULL, | |
| 54 | + | -- Subscribers emailed about it; null when none were. | |
| 55 | + | notified INTEGER | |
| 56 | + | ); | |
| 57 | + | CREATE INDEX IF NOT EXISTS incident_timeline_incident ON incident_timeline (incident_id, at); | |
| 58 | + | CREATE INDEX IF NOT EXISTS incident_timeline_public ON incident_timeline (public, at); | |
| 59 | + | ||
| 60 | + | CREATE TABLE IF NOT EXISTS incident_followup ( | |
| 61 | + | id TEXT PRIMARY KEY, | |
| 62 | + | incident_id TEXT NOT NULL, | |
| 63 | + | title TEXT NOT NULL, | |
| 64 | + | owner TEXT, | |
| 65 | + | done_at TEXT, | |
| 66 | + | done_by TEXT, | |
| 67 | + | created_at TEXT NOT NULL, | |
| 68 | + | created_by TEXT NOT NULL | |
| 69 | + | ); | |
| 70 | + | CREATE INDEX IF NOT EXISTS incident_followup_incident ON incident_followup (incident_id, created_at); | |
| 71 | + | ||
| 72 | + | CREATE TABLE IF NOT EXISTS postmortem ( | |
| 73 | + | incident_id TEXT PRIMARY KEY, | |
| 74 | + | summary TEXT NOT NULL DEFAULT '', | |
| 75 | + | impact TEXT NOT NULL DEFAULT '', | |
| 76 | + | timeline TEXT NOT NULL DEFAULT '', | |
| 77 | + | root_cause TEXT NOT NULL DEFAULT '', | |
| 78 | + | went_well TEXT NOT NULL DEFAULT '', | |
| 79 | + | went_badly TEXT NOT NULL DEFAULT '', | |
| 80 | + | action_items TEXT NOT NULL DEFAULT '', | |
| 81 | + | updated_at TEXT NOT NULL, | |
| 82 | + | updated_by TEXT NOT NULL, | |
| 83 | + | published_at TEXT, | |
| 84 | + | published_by TEXT | |
| 85 | + | ); | |
| 86 | + | ||
| 87 | + | CREATE TABLE IF NOT EXISTS maintenance ( | |
| 88 | + | id TEXT PRIMARY KEY, | |
| 89 | + | title TEXT NOT NULL, | |
| 90 | + | message TEXT NOT NULL, | |
| 91 | + | -- A JSON array of component keys. | |
| 92 | + | components TEXT NOT NULL, | |
| 93 | + | starts_at TEXT NOT NULL, | |
| 94 | + | ends_at TEXT NOT NULL, | |
| 95 | + | state TEXT NOT NULL CHECK (state IN ('scheduled', 'in_progress', 'completed', 'cancelled')), | |
| 96 | + | -- Whether subscribers hear about it when it starts and ends. | |
| 97 | + | notify INTEGER NOT NULL DEFAULT 0, | |
| 98 | + | created_at TEXT NOT NULL, | |
| 99 | + | created_by TEXT NOT NULL | |
| 100 | + | ); | |
| 101 | + | CREATE INDEX IF NOT EXISTS maintenance_window ON maintenance (state, starts_at); | |
| 102 | + | ||
| 103 | + | CREATE TABLE IF NOT EXISTS maintenance_update ( | |
| 104 | + | id TEXT PRIMARY KEY, | |
| 105 | + | maintenance_id TEXT NOT NULL, | |
| 106 | + | at TEXT NOT NULL, | |
| 107 | + | by TEXT NOT NULL, | |
| 108 | + | text TEXT NOT NULL, | |
| 109 | + | notified INTEGER | |
| 110 | + | ); | |
| 111 | + | CREATE INDEX IF NOT EXISTS maintenance_update_maintenance ON maintenance_update (maintenance_id, at); | |
| 112 | + | ||
| 113 | + | -- Email subscribers. Only hashes of tokens are kept: confirm_hash is the | |
| 114 | + | -- SHA-256 of the confirmation link's token; unsubscribe links are signed | |
| 115 | + | -- with STATUS_SECRET and not stored. `components` is a JSON array of keys, | |
| 116 | + | -- or null for everything; `pending_components` is what the unconfirmed | |
| 117 | + | -- request asked for, applied on confirming. | |
| 118 | + | CREATE TABLE IF NOT EXISTS subscriber ( | |
| 119 | + | id TEXT PRIMARY KEY, | |
| 120 | + | email TEXT NOT NULL UNIQUE, | |
| 121 | + | components TEXT, | |
| 122 | + | pending_components TEXT, | |
| 123 | + | confirm_hash TEXT, | |
| 124 | + | confirm_expires_at TEXT, | |
| 125 | + | confirm_sent_at TEXT, | |
| 126 | + | confirmed_at TEXT, | |
| 127 | + | created_at TEXT NOT NULL | |
| 128 | + | ); | |
| 129 | + | CREATE INDEX IF NOT EXISTS subscriber_confirm ON subscriber (confirm_hash); | |
| 130 | + | ||
| 131 | + | -- A part's current run of failed or slow checks, for detection. | |
| 132 | + | CREATE TABLE IF NOT EXISTS streak ( | |
| 133 | + | component TEXT PRIMARY KEY, | |
| 134 | + | state TEXT NOT NULL, | |
| 135 | + | count INTEGER NOT NULL, | |
| 136 | + | since TEXT NOT NULL, | |
| 137 | + | alerted INTEGER NOT NULL DEFAULT 0 | |
| 138 | + | ); | |
| 139 | + | ||
| 140 | + | -- Every staff change, with who made it; sudo's audit log reads it. | |
| 141 | + | CREATE TABLE IF NOT EXISTS audit ( | |
| 142 | + | id TEXT PRIMARY KEY, | |
| 143 | + | at TEXT NOT NULL, | |
| 144 | + | by TEXT NOT NULL, | |
| 145 | + | action TEXT NOT NULL, | |
| 146 | + | target TEXT NOT NULL, | |
| 147 | + | detail TEXT NOT NULL | |
| 148 | + | ); | |
| 149 | + | CREATE INDEX IF NOT EXISTS audit_at ON audit (at); | |
| 150 | + | ||
| 151 | + | -- The incidents posted before this migration. | |
| 152 | + | INSERT OR IGNORE INTO incident | |
| 153 | + | (id, title, severity, status, visibility, source, started_at, declared_at, acknowledged_at, mitigated_at, resolved_at, published_at, created_by) | |
| 154 | + | SELECT id, title, CASE impact WHEN 'down' THEN 'sev2' ELSE 'sev3' END, status, 'public', 'declared', | |
| 155 | + | started_at, started_at, started_at, resolved_at, resolved_at, started_at, created_by | |
| 156 | + | FROM incidents; | |
| 157 | + | ||
| 158 | + | INSERT OR IGNORE INTO incident_component (incident_id, component, impact) | |
| 159 | + | SELECT i.id, j.value, CASE i.impact WHEN 'down' THEN 'major_outage' ELSE 'partial_outage' END | |
| 160 | + | FROM incidents i, json_each(i.components) j; | |
| 161 | + | ||
| 162 | + | INSERT OR IGNORE INTO incident_timeline (id, incident_id, at, by, kind, public, status, text) | |
| 163 | + | SELECT id, incident_id, at, by, 'update', 1, status, message FROM incident_updates; |
| 1 | + | { | |
| 2 | + | "name": "@g1t/status", | |
| 3 | + | "version": "0.1.0", | |
| 4 | + | "private": true, | |
| 5 | + | "type": "module", | |
| 6 | + | "license": "MIT", | |
| 7 | + | "scripts": { | |
| 8 | + | "test": "node --experimental-transform-types --no-warnings=ExperimentalWarning --test src/*.test.ts", | |
| 9 | + | "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json", | |
| 10 | + | "dev": "wrangler dev --test-scheduled", | |
| 11 | + | "deploy": "wrangler deploy" | |
| 12 | + | }, | |
| 13 | + | "dependencies": { | |
| 14 | + | "@g1t/contracts": "*", | |
| 15 | + | "@g1t/theme": "*" | |
| 16 | + | } | |
| 17 | + | } |
| 1 | + | /** | |
| 2 | + | * The parts of g1t the page lists, and how each is checked. Every address | |
| 3 | + | * comes from the Worker's vars, so an installation of its own names its own | |
| 4 | + | * hosts; a part whose address is empty is not part of that installation | |
| 5 | + | * and is left off the page. | |
| 6 | + | * | |
| 7 | + | * Checks go over the public internet, as people reach g1t, so they measure | |
| 8 | + | * what people see. Billing has no public address of its own, so it is the | |
| 9 | + | * one part checked through a service binding, when there is one. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | /** The Worker's vars that say where things are. */ | |
| 13 | + | export type Targets = { | |
| 14 | + | /** The site: `https://g1t.sh`. */ | |
| 15 | + | SITE_URL: string; | |
| 16 | + | /** The API: `https://api.g1t.sh`. */ | |
| 17 | + | API_URL: string; | |
| 18 | + | MCP_URL: string; | |
| 19 | + | DOCS_URL: string; | |
| 20 | + | /** Where deployed apps live: `https://g1t.page`. */ | |
| 21 | + | PAGES_URL: string; | |
| 22 | + | MODELS_URL: string; | |
| 23 | + | /** A public repository that always exists, `owner/name`, to fetch refs of. */ | |
| 24 | + | PROBE_REPO: string; | |
| 25 | + | /** The site as browsers reach it, when the checks reach it another way. */ | |
| 26 | + | PUBLIC_SITE_URL?: string; | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | /** One request a check makes, and the answer that means the part works. */ | |
| 30 | + | export type Step = { | |
| 31 | + | url: string; | |
| 32 | + | headers?: Record<string, string>; | |
| 33 | + | /** The status that means it works; any 2xx when absent. */ | |
| 34 | + | expect?: number; | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | export type Check = | |
| 38 | + | | { kind: "http"; steps: Step[] } | |
| 39 | + | /** Billing, through its binding: reading its price book. */ | |
| 40 | + | | { kind: "billing" } | |
| 41 | + | /** No check yet: the page says so rather than showing green. */ | |
| 42 | + | | { kind: "none" }; | |
| 43 | + | ||
| 44 | + | export type ComponentInfo = { | |
| 45 | + | key: string; | |
| 46 | + | name: string; | |
| 47 | + | address: string; | |
| 48 | + | checks: string; | |
| 49 | + | /** Whether g1t as a whole is down when this is. */ | |
| 50 | + | core: boolean; | |
| 51 | + | check: Check; | |
| 52 | + | }; | |
| 53 | + | ||
| 54 | + | /** A token no one holds: the API asks identity about it and refuses it. */ | |
| 55 | + | export const NO_TOKEN = `g1t_${"0".repeat(40)}`; | |
| 56 | + | ||
| 57 | + | function host(url: string): string { | |
| 58 | + | try { | |
| 59 | + | return new URL(url).host; | |
| 60 | + | } catch { | |
| 61 | + | return url; | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | const trim = (url: string | undefined) => (url ?? "").trim().replace(/\/+$/, ""); | |
| 66 | + | ||
| 67 | + | /** The parts, in the order the page lists them. */ | |
| 68 | + | export function components(vars: Partial<Targets>, billing = true): ComponentInfo[] { | |
| 69 | + | const site = trim(vars.SITE_URL); | |
| 70 | + | const api = trim(vars.API_URL); | |
| 71 | + | const mcp = trim(vars.MCP_URL); | |
| 72 | + | const docs = trim(vars.DOCS_URL); | |
| 73 | + | const pages = trim(vars.PAGES_URL); | |
| 74 | + | const models = trim(vars.MODELS_URL); | |
| 75 | + | const repo = (vars.PROBE_REPO ?? "").trim(); | |
| 76 | + | // What the page shows as the site's address: where people reach it. | |
| 77 | + | const shown = host(trim(vars.PUBLIC_SITE_URL) || site); | |
| 78 | + | const list: (ComponentInfo | null)[] = [ | |
| 79 | + | site | |
| 80 | + | ? { | |
| 81 | + | key: "site", | |
| 82 | + | name: "Website and sign-in", | |
| 83 | + | address: shown, | |
| 84 | + | checks: api | |
| 85 | + | ? "The sign-in page loading, and the account service answering an access token through the API." | |
| 86 | + | : "The sign-in page loading.", | |
| 87 | + | core: true, | |
| 88 | + | check: { | |
| 89 | + | kind: "http", | |
| 90 | + | steps: [ | |
| 91 | + | { url: `${site}/login` }, | |
| 92 | + | ...(api ? [{ url: `${api}/v1/user`, headers: { authorization: `Bearer ${NO_TOKEN}` }, expect: 401 }] : []), | |
| 93 | + | ], | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | : null, | |
| 97 | + | api | |
| 98 | + | ? { | |
| 99 | + | key: "api", | |
| 100 | + | name: "API", | |
| 101 | + | address: host(api), | |
| 102 | + | checks: "The API's front page.", | |
| 103 | + | core: true, | |
| 104 | + | check: { kind: "http", steps: [{ url: `${api}/` }] }, | |
| 105 | + | } | |
| 106 | + | : null, | |
| 107 | + | site && repo | |
| 108 | + | ? { | |
| 109 | + | key: "git", | |
| 110 | + | name: "Git and repositories", | |
| 111 | + | address: `${shown}/<owner>/<repo>.git`, | |
| 112 | + | checks: "Listing the branches of a public repository over HTTPS, the first step of every clone.", | |
| 113 | + | core: true, | |
| 114 | + | check: { kind: "http", steps: [{ url: `${site}/${repo}.git/info/refs?service=git-upload-pack` }] }, | |
| 115 | + | } | |
| 116 | + | : null, | |
| 117 | + | mcp | |
| 118 | + | ? { | |
| 119 | + | key: "mcp", | |
| 120 | + | name: "MCP server", | |
| 121 | + | address: host(mcp), | |
| 122 | + | checks: "The MCP server's description.", | |
| 123 | + | core: false, | |
| 124 | + | check: { kind: "http", steps: [{ url: `${mcp}/`, headers: { accept: "application/json" } }] }, | |
| 125 | + | } | |
| 126 | + | : null, | |
| 127 | + | docs | |
| 128 | + | ? { | |
| 129 | + | key: "docs", | |
| 130 | + | name: "Documentation", | |
| 131 | + | address: host(docs), | |
| 132 | + | checks: "The documentation's front page.", | |
| 133 | + | core: false, | |
| 134 | + | check: { kind: "http", steps: [{ url: `${docs}/` }] }, | |
| 135 | + | } | |
| 136 | + | : null, | |
| 137 | + | pages | |
| 138 | + | ? { | |
| 139 | + | key: "deployments", | |
| 140 | + | name: "Deployments", | |
| 141 | + | address: `*.${host(pages)}`, | |
| 142 | + | checks: `${host(pages)} answering. Each deployed app is not checked one by one.`, | |
| 143 | + | core: false, | |
| 144 | + | check: { kind: "http", steps: [{ url: `${pages}/` }] }, | |
| 145 | + | } | |
| 146 | + | : null, | |
| 147 | + | models | |
| 148 | + | ? { | |
| 149 | + | key: "agents", | |
| 150 | + | name: "Agents' model proxy", | |
| 151 | + | address: host(models), | |
| 152 | + | checks: "The proxy agents reach their model through. The model providers behind it are not checked.", | |
| 153 | + | core: false, | |
| 154 | + | check: { kind: "http", steps: [{ url: `${models}/` }] }, | |
| 155 | + | } | |
| 156 | + | : null, | |
| 157 | + | models | |
| 158 | + | ? { | |
| 159 | + | key: "sandboxes", | |
| 160 | + | name: "Sandboxes", | |
| 161 | + | address: "Agents, checks, workflows and builds", | |
| 162 | + | checks: "Starting a sandbox costs money and takes seconds, so it is not checked from here yet.", | |
| 163 | + | core: false, | |
| 164 | + | check: { kind: "none" }, | |
| 165 | + | } | |
| 166 | + | : null, | |
| 167 | + | billing && site | |
| 168 | + | ? { | |
| 169 | + | key: "billing", | |
| 170 | + | name: "Billing", | |
| 171 | + | address: `${shown}/<workspace>/-/billing`, | |
| 172 | + | checks: "The billing service reading its price book. Stripe itself is not checked.", | |
| 173 | + | core: false, | |
| 174 | + | check: { kind: "billing" }, | |
| 175 | + | } | |
| 176 | + | : null, | |
| 177 | + | ]; | |
| 178 | + | return list.filter((c): c is ComponentInfo => c != null); | |
| 179 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { DETECT_AFTER, type Streak, detect, detectedImpact, draftTitle } from "./detect.ts"; | |
| 5 | + | ||
| 6 | + | const at = (minute: number) => new Date(Date.UTC(2026, 9, 5, 12, minute)); | |
| 7 | + | ||
| 8 | + | /** Runs rounds of checks through detection, carrying the streaks along. */ | |
| 9 | + | function run(rounds: Record<string, "up" | "degraded" | "down">[], open: { id: string; components: string[] }[] = [], maintenance = new Set<string>()) { | |
| 10 | + | let streaks = new Map<string, Streak>(); | |
| 11 | + | const results = rounds.map((round, minute) => { | |
| 12 | + | const found = detect(streaks, Object.entries(round).map(([component, state]) => ({ component, state })), open, maintenance, at(minute)); | |
| 13 | + | streaks = new Map(found.streaks.map((s) => [s.component, s])); | |
| 14 | + | return found; | |
| 15 | + | }); | |
| 16 | + | return results; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | test(`a draft after ${DETECT_AFTER} failed checks in a row, once`, () => { | |
| 20 | + | assert.equal(DETECT_AFTER, 3); | |
| 21 | + | const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }, { api: "down" }]); | |
| 22 | + | assert.deepEqual(rounds.map((r) => r.draft.length), [0, 0, 1, 0]); | |
| 23 | + | assert.deepEqual(rounds[2]!.draft[0], { key: "api", state: "down", since: at(0).toISOString() }); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("a blip is not an incident: a good check resets the run", () => { | |
| 27 | + | const rounds = run([{ api: "down" }, { api: "down" }, { api: "up" }, { api: "down" }, { api: "down" }]); | |
| 28 | + | assert.ok(rounds.every((r) => r.draft.length === 0)); | |
| 29 | + | assert.equal(rounds[4]!.streaks[0]!.count, 2); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("slow then failing is one run, at its worst", () => { | |
| 33 | + | const rounds = run([{ docs: "degraded" }, { docs: "down" }, { docs: "degraded" }]); | |
| 34 | + | assert.deepEqual(rounds[2]!.draft, [{ key: "docs", state: "down", since: at(0).toISOString() }]); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("parts failing together share one draft", () => { | |
| 38 | + | const rounds = run([{ api: "down", git: "down" }, { api: "down", git: "down" }, { api: "down", git: "down" }]); | |
| 39 | + | assert.deepEqual(rounds[2]!.draft.map((d) => d.key), ["api", "git"]); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | test("with an incident already open on the part: a line on it, no draft; recovery is noted", () => { | |
| 43 | + | const open = [{ id: "inc1", components: ["api"] }]; | |
| 44 | + | const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }, { api: "up" }], open); | |
| 45 | + | assert.equal(rounds[2]!.draft.length, 0); | |
| 46 | + | assert.deepEqual(rounds[2]!.failing, [{ incident: "inc1", key: "api", state: "down", since: at(0).toISOString() }]); | |
| 47 | + | assert.deepEqual(rounds[3]!.recovered, [{ incident: "inc1", key: "api", since: at(0).toISOString(), checks: 3 }]); | |
| 48 | + | assert.equal(rounds[3]!.streaks.length, 0); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("a short blip that never crossed the line is not noted as a recovery", () => { | |
| 52 | + | const rounds = run([{ api: "down" }, { api: "up" }], [{ id: "inc1", components: ["api"] }]); | |
| 53 | + | assert.equal(rounds[1]!.recovered.length, 0); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("parts under maintenance and unmonitored parts are left alone", () => { | |
| 57 | + | const rounds = run([{ api: "down" }, { api: "down" }, { api: "down" }], [], new Set(["api"])); | |
| 58 | + | assert.ok(rounds.every((r) => r.draft.length === 0 && r.streaks.length === 0)); | |
| 59 | + | const none = detect(new Map(), [{ component: "sandboxes", state: "unmonitored" }], [], new Set(), at(0)); | |
| 60 | + | assert.equal(none.streaks.length, 0); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("the draft says what failed", () => { | |
| 64 | + | assert.equal(detectedImpact("down"), "major_outage"); | |
| 65 | + | assert.equal(detectedImpact("degraded"), "degraded"); | |
| 66 | + | assert.equal(draftTitle([{ name: "API", state: "down" }, { name: "Git", state: "down" }]), "Detected: API and Git not answering"); | |
| 67 | + | assert.equal( | |
| 68 | + | draftTitle([{ name: "API", state: "down" }, { name: "Git", state: "down" }, { name: "MCP", state: "down" }, { name: "Docs", state: "degraded" }]), | |
| 69 | + | "Detected: API, Git and MCP not answering; Docs slow", | |
| 70 | + | ); | |
| 71 | + | }); |
| 1 | + | /** | |
| 2 | + | * Noticing trouble before anyone reports it: when a part fails (or is | |
| 3 | + | * slow) for DETECT_AFTER checks in a row, a draft incident is made for | |
| 4 | + | * staff in sudo, not shown on the status page until someone publishes it. | |
| 5 | + | * When a part that had crossed that line answers again, open incidents on | |
| 6 | + | * it get a note. No Workers imports, so it is tested under Node. | |
| 7 | + | */ | |
| 8 | + | import type { ComponentImpact, StatusComponentState } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | /** Checks in a row, a minute apart, before a draft is made. */ | |
| 11 | + | export const DETECT_AFTER = 3; | |
| 12 | + | ||
| 13 | + | export type Streak = { | |
| 14 | + | component: string; | |
| 15 | + | /** The worst seen in this run of failures. */ | |
| 16 | + | state: "degraded" | "down"; | |
| 17 | + | count: number; | |
| 18 | + | /** When the run began. */ | |
| 19 | + | since: string; | |
| 20 | + | /** Whether it has crossed the line, and been raised. */ | |
| 21 | + | alerted: boolean; | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | /** An open incident (draft or public), with the parts it affects. */ | |
| 25 | + | export type OpenRef = { id: string; components: string[] }; | |
| 26 | + | ||
| 27 | + | export type Detection = { | |
| 28 | + | /** Every failing part's run after this round; parts not here have none. */ | |
| 29 | + | streaks: Streak[]; | |
| 30 | + | /** Parts that crossed the line with no open incident on them: one draft for all. */ | |
| 31 | + | draft: { key: string; state: "degraded" | "down"; since: string }[]; | |
| 32 | + | /** Parts that crossed the line while an incident on them was open. */ | |
| 33 | + | failing: { incident: string; key: string; state: "degraded" | "down"; since: string }[]; | |
| 34 | + | /** Parts answering again after crossing the line. */ | |
| 35 | + | recovered: { incident: string; key: string; since: string; checks: number }[]; | |
| 36 | + | }; | |
| 37 | + | ||
| 38 | + | export function detect( | |
| 39 | + | previous: Map<string, Streak>, | |
| 40 | + | observations: { component: string; state: StatusComponentState }[], | |
| 41 | + | open: OpenRef[], | |
| 42 | + | maintenance: Set<string>, | |
| 43 | + | at: Date, | |
| 44 | + | threshold = DETECT_AFTER, | |
| 45 | + | ): Detection { | |
| 46 | + | const out: Detection = { streaks: [], draft: [], failing: [], recovered: [] }; | |
| 47 | + | const covering = (key: string) => open.filter((i) => i.components.includes(key)); | |
| 48 | + | for (const { component: key, state } of observations) { | |
| 49 | + | const prev = previous.get(key); | |
| 50 | + | if (state === "unmonitored" || maintenance.has(key)) continue; | |
| 51 | + | if (state !== "degraded" && state !== "down") { | |
| 52 | + | if (prev?.alerted) for (const i of covering(key)) out.recovered.push({ incident: i.id, key, since: prev.since, checks: prev.count }); | |
| 53 | + | continue; | |
| 54 | + | } | |
| 55 | + | const streak: Streak = prev | |
| 56 | + | ? { ...prev, count: prev.count + 1, state: prev.state === "down" || state === "down" ? "down" : "degraded" } | |
| 57 | + | : { component: key, state, count: 1, since: at.toISOString(), alerted: false }; | |
| 58 | + | if (!streak.alerted && streak.count >= threshold) { | |
| 59 | + | streak.alerted = true; | |
| 60 | + | const incidents = covering(key); | |
| 61 | + | if (incidents.length) for (const i of incidents) out.failing.push({ incident: i.id, key, state: streak.state, since: streak.since }); | |
| 62 | + | else out.draft.push({ key, state: streak.state, since: streak.since }); | |
| 63 | + | } | |
| 64 | + | out.streaks.push(streak); | |
| 65 | + | } | |
| 66 | + | return out; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** What a detected failure does to its part, for the draft. */ | |
| 70 | + | export function detectedImpact(state: "degraded" | "down"): ComponentImpact { | |
| 71 | + | return state === "down" ? "major_outage" : "degraded"; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** The draft's title: "Detected: API and Git not answering". */ | |
| 75 | + | export function draftTitle(parts: { name: string; state: "degraded" | "down" }[]): string { | |
| 76 | + | const list = (names: string[]) => (names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 77 | + | const down = parts.filter((p) => p.state === "down").map((p) => p.name); | |
| 78 | + | const slow = parts.filter((p) => p.state === "degraded").map((p) => p.name); | |
| 79 | + | const said = [down.length ? `${list(down)} not answering` : "", slow.length ? `${list(slow)} slow` : ""].filter(Boolean).join("; "); | |
| 80 | + | return `Detected: ${said}`.slice(0, 120); | |
| 81 | + | } |
| 1 | + | /** | |
| 2 | + | * Email from the status page: subscription confirmations, incident and | |
| 3 | + | * maintenance updates to subscribers, and the staff alert when a draft is | |
| 4 | + | * detected. Sending goes through a `Sender`, so the page works the same | |
| 5 | + | * with no email at all (an installation without it, or tests): the feeds | |
| 6 | + | * still carry every update. On g1t.sh the sender is Cloudflare Email | |
| 7 | + | * Sending, the `send_email` binding identity uses too. No Workers imports. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | export type Mail = { to: string; subject: string; text: string; html: string; headers?: Record<string, string> }; | |
| 11 | + | ||
| 12 | + | export interface Sender { | |
| 13 | + | send(mail: Mail): Promise<void>; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** The `send_email` binding, as Cloudflare Email Sending gives it. */ | |
| 17 | + | export type EmailBinding = { | |
| 18 | + | send(message: { to: string; from: string; subject: string; text: string; html: string; headers?: Record<string, string> }): Promise<unknown>; | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | export const DEFAULT_FROM = "g1t status <noreply@g1t.sh>"; | |
| 22 | + | ||
| 23 | + | /** A sender over the binding, or null when there is none. */ | |
| 24 | + | export function bindingSender(binding: EmailBinding | undefined, from = DEFAULT_FROM): Sender | null { | |
| 25 | + | if (!binding || typeof binding.send !== "function") return null; | |
| 26 | + | return { send: async ({ to, subject, text, html, headers }) => void (await binding.send({ to, from, subject, text, html, ...(headers ? { headers } : {}) })) }; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Collects mail instead of sending it: for tests and previews. */ | |
| 30 | + | export function memorySender(): Sender & { sent: Mail[] } { | |
| 31 | + | const sent: Mail[] = []; | |
| 32 | + | return { sent, send: async (mail) => void sent.push(mail) }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function escape(text: string): string { | |
| 36 | + | return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export type Letter = { | |
| 40 | + | /** A line over the body, such as "Identified · Pushes failing". */ | |
| 41 | + | heading: string; | |
| 42 | + | paragraphs: string[]; | |
| 43 | + | action?: { label: string; url: string }; | |
| 44 | + | footer: string[]; | |
| 45 | + | /** Where the reader leaves, as a link in the footer. */ | |
| 46 | + | unsubscribe?: string; | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | /** The plain text and HTML of a letter; everything is escaped. */ | |
| 50 | + | export function render(letter: Letter): { text: string; html: string } { | |
| 51 | + | const text: string[] = [letter.heading, ""]; | |
| 52 | + | let html = `<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:32px 16px;color:#16150f">`; | |
| 53 | + | html += `<p style="margin:0 0 6px;font-size:13px;color:#6e6a5e">g1t status</p>`; | |
| 54 | + | html += `<h1 style="margin:0 0 16px;font-size:19px;line-height:1.35">${escape(letter.heading)}</h1>`; | |
| 55 | + | for (const p of letter.paragraphs) { | |
| 56 | + | text.push(p, ""); | |
| 57 | + | html += `<p style="font-size:15px;line-height:1.6;white-space:pre-line">${escape(p)}</p>`; | |
| 58 | + | } | |
| 59 | + | if (letter.action) { | |
| 60 | + | text.push(`${letter.action.label}: ${letter.action.url}`, ""); | |
| 61 | + | html += `<p style="margin:24px 0"><a href="${escape(letter.action.url)}" style="background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px">${escape(letter.action.label)}</a></p>`; | |
| 62 | + | } | |
| 63 | + | for (const line of letter.footer) { | |
| 64 | + | text.push(line); | |
| 65 | + | html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0">${escape(line)}</p>`; | |
| 66 | + | } | |
| 67 | + | if (letter.unsubscribe) { | |
| 68 | + | text.push(`Unsubscribe: ${letter.unsubscribe}`); | |
| 69 | + | html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0"><a href="${escape(letter.unsubscribe)}" style="color:#6e6a5e">Unsubscribe</a></p>`; | |
| 70 | + | } | |
| 71 | + | html += "</div>"; | |
| 72 | + | return { text: `${text.join("\n").trim()}\n`, html }; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /** The headers that let mail apps offer one-click unsubscribing (RFC 8058). */ | |
| 76 | + | export function unsubscribeHeaders(url: string): Record<string, string> { | |
| 77 | + | return { "List-Unsubscribe": `<${url}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" }; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | export function confirmLetter(link: string, parts: string[] | null): Letter { | |
| 81 | + | return { | |
| 82 | + | heading: "Confirm your subscription to g1t status", | |
| 83 | + | paragraphs: [ | |
| 84 | + | `Someone, hopefully you, asked to get an email when g1t posts an incident or planned maintenance${parts ? ` affecting ${parts.join(", ")}` : ""}. Confirm to start.`, | |
| 85 | + | ], | |
| 86 | + | action: { label: "Confirm subscription", url: link }, | |
| 87 | + | footer: ["The link works for 24 hours. If you did not ask for this, ignore this email and nothing happens."], | |
| 88 | + | }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | export function updateLetter(input: { heading: string; text: string; url: string; affects: string[]; unsubscribe: string }): Letter { | |
| 92 | + | return { | |
| 93 | + | heading: input.heading, | |
| 94 | + | paragraphs: [input.text, ...(input.affects.length ? [`Affects: ${input.affects.join(", ")}.`] : [])], | |
| 95 | + | action: { label: "See it on the status page", url: input.url }, | |
| 96 | + | footer: ["You get these because you subscribed at status.g1t.sh."], | |
| 97 | + | unsubscribe: input.unsubscribe, | |
| 98 | + | }; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | export function alertLetter(input: { title: string; parts: string[]; since: string; link: string }): Letter { | |
| 102 | + | return { | |
| 103 | + | heading: input.title, | |
| 104 | + | paragraphs: [ | |
| 105 | + | `The checks have failed for ${input.parts.join(", ")} since ${input.since}, three times in a row. A draft incident is waiting in sudo. It is not on the status page until someone publishes it.`, | |
| 106 | + | ], | |
| 107 | + | action: { label: "Open it in sudo", url: input.link }, | |
| 108 | + | footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."], | |
| 109 | + | }; | |
| 110 | + | } |
| 1 | + | /** | |
| 2 | + | * The status page's feeds: every public incident update and maintenance | |
| 3 | + | * notice, newest first, as Atom (`/feed.xml`) and JSON Feed | |
| 4 | + | * (`/feed.json`). No Workers imports, so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { StatusIncident, StatusMaintenance } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { INCIDENT_STATUS } from "./model.ts"; | |
| 9 | + | ||
| 10 | + | export type FeedItem = { | |
| 11 | + | /** Unique and stable: the page's address with the update's anchor. */ | |
| 12 | + | id: string; | |
| 13 | + | url: string; | |
| 14 | + | title: string; | |
| 15 | + | text: string; | |
| 16 | + | at: string; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | /** How many items a feed carries. */ | |
| 20 | + | export const FEED_SIZE = 50; | |
| 21 | + | ||
| 22 | + | /** Feed items from incidents and maintenance, newest first. */ | |
| 23 | + | export function feedItems(incidents: StatusIncident[], maintenance: StatusMaintenance[], size = FEED_SIZE): FeedItem[] { | |
| 24 | + | const items: FeedItem[] = []; | |
| 25 | + | for (const incident of incidents) { | |
| 26 | + | for (const update of incident.updates) { | |
| 27 | + | items.push({ | |
| 28 | + | id: `${incident.url}#update-${update.id}`, | |
| 29 | + | url: `${incident.url}#update-${update.id}`, | |
| 30 | + | title: `${INCIDENT_STATUS[update.status]}: ${incident.title}`, | |
| 31 | + | text: update.text, | |
| 32 | + | at: update.at, | |
| 33 | + | }); | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | for (const m of maintenance) { | |
| 37 | + | for (const update of m.updates) { | |
| 38 | + | items.push({ id: `${m.url}#update-${update.id}`, url: `${m.url}#update-${update.id}`, title: `Maintenance: ${m.title}`, text: update.text, at: update.at }); | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return items.sort((a, b) => b.at.localeCompare(a.at)).slice(0, size); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function xml(text: string): string { | |
| 45 | + | return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | export type FeedOptions = { origin: string; title: string; updated: string }; | |
| 49 | + | ||
| 50 | + | export function atom(items: FeedItem[], { origin, title, updated }: FeedOptions): string { | |
| 51 | + | const entries = items | |
| 52 | + | .map( | |
| 53 | + | (item) => `<entry> | |
| 54 | + | <id>${xml(item.id)}</id> | |
| 55 | + | <title>${xml(item.title)}</title> | |
| 56 | + | <link rel="alternate" type="text/html" href="${xml(item.url)}"/> | |
| 57 | + | <updated>${xml(item.at)}</updated> | |
| 58 | + | <published>${xml(item.at)}</published> | |
| 59 | + | <content type="text">${xml(item.text)}</content> | |
| 60 | + | </entry>`, | |
| 61 | + | ) | |
| 62 | + | .join("\n"); | |
| 63 | + | return `<?xml version="1.0" encoding="utf-8"?> | |
| 64 | + | <feed xmlns="http://www.w3.org/2005/Atom"> | |
| 65 | + | <id>${xml(`${origin}/`)}</id> | |
| 66 | + | <title>${xml(title)}</title> | |
| 67 | + | <link rel="self" type="application/atom+xml" href="${xml(`${origin}/feed.xml`)}"/> | |
| 68 | + | <link rel="alternate" type="text/html" href="${xml(`${origin}/`)}"/> | |
| 69 | + | <updated>${xml(items[0]?.at ?? updated)}</updated> | |
| 70 | + | <author><name>g1t</name></author> | |
| 71 | + | ${entries} | |
| 72 | + | </feed> | |
| 73 | + | `; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** JSON Feed 1.1. */ | |
| 77 | + | export function jsonFeed(items: FeedItem[], { origin, title }: FeedOptions): object { | |
| 78 | + | return { | |
| 79 | + | version: "https://jsonfeed.org/version/1.1", | |
| 80 | + | title, | |
| 81 | + | home_page_url: `${origin}/`, | |
| 82 | + | feed_url: `${origin}/feed.json`, | |
| 83 | + | language: "en", | |
| 84 | + | items: items.map((item) => ({ id: item.id, url: item.url, title: item.title, content_text: item.text, date_published: item.at })), | |
| 85 | + | }; | |
| 86 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | type IncidentFacts, | |
| 6 | + | applyChange, | |
| 7 | + | applyRoles, | |
| 8 | + | checkChange, | |
| 9 | + | checkDeclare, | |
| 10 | + | checkMaintenance, | |
| 11 | + | checkMaintenanceChange, | |
| 12 | + | checkPostmortem, | |
| 13 | + | duration, | |
| 14 | + | durations, | |
| 15 | + | notifyByDefault, | |
| 16 | + | postmortemReady, | |
| 17 | + | shortId, | |
| 18 | + | } from "./incidents.ts"; | |
| 19 | + | import { postmortemDraft, timelineText } from "./postmortem.ts"; | |
| 20 | + | ||
| 21 | + | const NOW = new Date("2026-10-05T12:00:00Z"); | |
| 22 | + | const KNOWN = ["git", "api", "docs"]; | |
| 23 | + | const base = { | |
| 24 | + | title: " Pushes failing ", | |
| 25 | + | severity: "sev2", | |
| 26 | + | message: "We are looking into it.\r\n\r\n\r\nMore soon.", | |
| 27 | + | components: [ | |
| 28 | + | { key: "git", impact: "major_outage" }, | |
| 29 | + | { key: "api", impact: "operational" }, | |
| 30 | + | ], | |
| 31 | + | commander: " Ana@G1T.sh ", | |
| 32 | + | notify: true, | |
| 33 | + | by: "staff@g1t.sh", | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | test("declaring: cleaned and checked; operational parts are left out", () => { | |
| 37 | + | const checked = checkDeclare(base, KNOWN, NOW); | |
| 38 | + | assert.ok(checked.ok); | |
| 39 | + | assert.equal(checked.value.title, "Pushes failing"); | |
| 40 | + | assert.equal(checked.value.status, "investigating"); | |
| 41 | + | assert.equal(checked.value.message, "We are looking into it.\n\nMore soon."); | |
| 42 | + | assert.deepEqual(checked.value.components, [{ key: "git", impact: "major_outage" }]); | |
| 43 | + | assert.equal(checked.value.commander, "ana@g1t.sh"); | |
| 44 | + | assert.equal(checked.value.started_at, null); | |
| 45 | + | assert.equal(checked.value.notify, true); | |
| 46 | + | }); | |
| 47 | + | ||
| 48 | + | test("declaring is refused without what it needs", () => { | |
| 49 | + | assert.equal(checkDeclare({ ...base, title: "" }, KNOWN, NOW).ok, false); | |
| 50 | + | assert.equal(checkDeclare({ ...base, severity: "sev9" }, KNOWN, NOW).ok, false); | |
| 51 | + | assert.equal(checkDeclare({ ...base, status: "resolved" }, KNOWN, NOW).ok, false); | |
| 52 | + | assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "operational" }] }, KNOWN, NOW).ok, false); | |
| 53 | + | assert.equal(checkDeclare({ ...base, components: [{ key: "nope", impact: "degraded" }] }, KNOWN, NOW).ok, false); | |
| 54 | + | assert.equal(checkDeclare({ ...base, components: [{ key: "git", impact: "meh" }] }, KNOWN, NOW).ok, false); | |
| 55 | + | assert.equal(checkDeclare({ ...base, commander: "not an email" }, KNOWN, NOW).ok, false); | |
| 56 | + | assert.equal(checkDeclare({ ...base, message: " " }, KNOWN, NOW).ok, false); | |
| 57 | + | assert.equal(checkDeclare({ ...base, started_at: "2026-10-06T00:00:00Z" }, KNOWN, NOW).ok, false); | |
| 58 | + | const backdated = checkDeclare({ ...base, started_at: "2026-10-05T11:00:00Z" }, KNOWN, NOW); | |
| 59 | + | assert.ok(backdated.ok && backdated.value.started_at === "2026-10-05T11:00:00.000Z"); | |
| 60 | + | }); | |
| 61 | + | ||
| 62 | + | test("severities: SEV1 and SEV2 email subscribers by default", () => { | |
| 63 | + | assert.deepEqual( | |
| 64 | + | (["sev1", "sev2", "sev3", "sev4"] as const).map(notifyByDefault), | |
| 65 | + | [true, true, false, false], | |
| 66 | + | ); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("an update needs words when it is public; a note can be just a change", () => { | |
| 70 | + | assert.equal(checkChange({ public: true, message: " ", by: "x" }, KNOWN).ok, false); | |
| 71 | + | const note = checkChange({ public: false, message: "", severity: "sev1", by: "x" }, KNOWN); | |
| 72 | + | assert.ok(note.ok && note.value.severity === "sev1" && note.value.notify === false); | |
| 73 | + | assert.equal(checkChange({ public: true, message: "ok", status: "panicking", by: "x" }, KNOWN).ok, false); | |
| 74 | + | assert.equal(checkChange({ public: true, message: "ok", impacts: [{ key: "zzz", impact: "degraded" }], by: "x" }, KNOWN).ok, false); | |
| 75 | + | const notify = checkChange({ public: false, message: "internal", notify: true, by: "x" }, KNOWN); | |
| 76 | + | assert.ok(notify.ok && notify.value.notify === false, "a note is never emailed"); | |
| 77 | + | }); | |
| 78 | + | ||
| 79 | + | const open: IncidentFacts = { | |
| 80 | + | status: "investigating", | |
| 81 | + | severity: "sev2", | |
| 82 | + | visibility: "public", | |
| 83 | + | components: [{ key: "git", impact: "major_outage" }], | |
| 84 | + | started_at: "2026-10-05T11:30:00.000Z", | |
| 85 | + | acknowledged_at: "2026-10-05T11:35:00.000Z", | |
| 86 | + | mitigated_at: null, | |
| 87 | + | resolved_at: null, | |
| 88 | + | commander: null, | |
| 89 | + | communications: null, | |
| 90 | + | }; | |
| 91 | + | const names = new Map([["git", "Git and repositories"]]); | |
| 92 | + | ||
| 93 | + | function change(input: Record<string, unknown>) { | |
| 94 | + | const checked = checkChange({ by: "staff@g1t.sh", ...input }, KNOWN); | |
| 95 | + | assert.ok(checked.ok, checked.ok ? "" : checked.error); | |
| 96 | + | return checked.value; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | test("lifecycle: identified, then monitoring marks it mitigated, then resolved", () => { | |
| 100 | + | const a = applyChange(open, change({ public: true, status: "identified", message: "A bad deploy." }), NOW, names); | |
| 101 | + | assert.ok(a.ok); | |
| 102 | + | assert.equal(a.value.next.status, "identified"); | |
| 103 | + | assert.equal(a.value.next.mitigated_at, null); | |
| 104 | + | assert.deepEqual(a.value.entries.map((e) => [e.kind, e.public]), [["status", false], ["update", true]]); | |
| 105 | + | assert.equal(a.value.entries[1]!.status, "identified"); | |
| 106 | + | assert.equal(a.value.entries[0]!.text, "Investigating → Identified."); | |
| 107 | + | ||
| 108 | + | const b = applyChange(a.value.next, change({ public: true, status: "monitoring", message: "Rolled back." }), new Date("2026-10-05T12:10:00Z")); | |
| 109 | + | assert.ok(b.ok && b.value.next.mitigated_at === "2026-10-05T12:10:00.000Z"); | |
| 110 | + | ||
| 111 | + | const c = applyChange(b.value.next, change({ public: true, status: "resolved", message: "Fixed." }), new Date("2026-10-05T12:40:00Z")); | |
| 112 | + | assert.ok(c.ok); | |
| 113 | + | assert.equal(c.value.next.resolved_at, "2026-10-05T12:40:00.000Z"); | |
| 114 | + | assert.equal(c.value.next.mitigated_at, "2026-10-05T12:10:00.000Z", "mitigated keeps its first time"); | |
| 115 | + | assert.deepEqual(durations(c.value.next), { to_acknowledge: 300, to_mitigate: 2400, to_resolve: 4200 }); | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | test("lifecycle: resolving straight away marks it mitigated too; reopening clears resolved", () => { | |
| 119 | + | const done = applyChange(open, change({ public: true, status: "resolved", message: "It was a blip." }), NOW); | |
| 120 | + | assert.ok(done.ok && done.value.next.mitigated_at === NOW.toISOString() && done.value.next.resolved_at === NOW.toISOString()); | |
| 121 | + | const again = applyChange(done.value.next, change({ public: true, status: "investigating", message: "It is back." }), NOW); | |
| 122 | + | assert.ok(again.ok); | |
| 123 | + | assert.equal(again.value.next.resolved_at, null); | |
| 124 | + | assert.match(again.value.entries[0]!.text, /^Reopened/); | |
| 125 | + | }); | |
| 126 | + | ||
| 127 | + | test("lifecycle: a published incident's status changes only with public words", () => { | |
| 128 | + | const quiet = applyChange(open, change({ public: false, status: "identified", message: "Found it." }), NOW); | |
| 129 | + | assert.equal(quiet.ok, false); | |
| 130 | + | const note = applyChange(open, change({ public: false, message: "Paging the storage team." }), NOW); | |
| 131 | + | assert.ok(note.ok && note.value.entries.length === 1 && note.value.entries[0]!.kind === "note" && !note.value.entries[0]!.public); | |
| 132 | + | assert.equal(applyChange(open, change({ public: false, message: "" }), NOW).ok, false, "nothing to post"); | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | test("lifecycle: severity and impact changes are written on the timeline", () => { | |
| 136 | + | const r = applyChange(open, change({ public: false, message: "", severity: "sev1", impacts: [{ key: "git", impact: "partial_outage" }, { key: "api", impact: "degraded" }] }), NOW, names); | |
| 137 | + | assert.ok(r.ok); | |
| 138 | + | assert.equal(r.value.next.severity, "sev1"); | |
| 139 | + | assert.deepEqual(r.value.next.components, [{ key: "git", impact: "partial_outage" }, { key: "api", impact: "degraded" }]); | |
| 140 | + | assert.deepEqual(r.value.entries.map((e) => e.text), ["Severity SEV2 → SEV1.", "Git and repositories: Major outage → Partial outage.", "api: Operational → Degraded performance."]); | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | test("lifecycle: drafts take notes but nothing public; dismissed takes nothing; the first touch acknowledges", () => { | |
| 144 | + | const draft: IncidentFacts = { ...open, visibility: "draft", acknowledged_at: null }; | |
| 145 | + | assert.equal(applyChange(draft, change({ public: true, message: "Hi" }), NOW).ok, false); | |
| 146 | + | const note = applyChange(draft, change({ public: false, message: "Looking.", status: "identified" }), NOW); | |
| 147 | + | assert.ok(note.ok); | |
| 148 | + | assert.equal(note.value.next.acknowledged_at, NOW.toISOString()); | |
| 149 | + | assert.equal(note.value.entries[0]!.kind, "acknowledged"); | |
| 150 | + | assert.equal(note.value.next.status, "identified", "a draft's status can move without words"); | |
| 151 | + | assert.equal(applyChange({ ...open, visibility: "dismissed" }, change({ public: false, message: "x" }), NOW).ok, false); | |
| 152 | + | }); | |
| 153 | + | ||
| 154 | + | test("roles: each change is a line, and nobody is said plainly", () => { | |
| 155 | + | const r = applyRoles({ ...open, commander: "ana@g1t.sh" }, { commander: null, communications: "bo@g1t.sh", by: "x" }, NOW); | |
| 156 | + | assert.deepEqual(r.entries.map((e) => e.text), ["Incident commander: nobody.", "Communications: bo@g1t.sh."]); | |
| 157 | + | assert.equal(applyRoles(open, { commander: null, communications: null, by: "x" }, NOW).entries.length, 0); | |
| 158 | + | }); | |
| 159 | + | ||
| 160 | + | test("durations read plainly", () => { | |
| 161 | + | assert.equal(duration(null), "—"); | |
| 162 | + | assert.equal(duration(42), "42s"); | |
| 163 | + | assert.equal(duration(720), "12m"); | |
| 164 | + | assert.equal(duration(7500), "2h 05m"); | |
| 165 | + | assert.equal(duration(3 * 86400 + 4 * 3600), "3d 4h"); | |
| 166 | + | }); | |
| 167 | + | ||
| 168 | + | test("maintenance: a window in the future, under 72 hours, on known parts", () => { | |
| 169 | + | const m = { title: "Database upgrade", message: "Pushes pause for a few minutes.", components: ["git"], starts_at: "2026-10-06T02:00Z", ends_at: "2026-10-06T03:00Z", by: "x" }; | |
| 170 | + | const checked = checkMaintenance(m, KNOWN, NOW); | |
| 171 | + | assert.ok(checked.ok && checked.value.ends_at === "2026-10-06T03:00:00.000Z"); | |
| 172 | + | assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-06T01:00Z" }, KNOWN, NOW).ok, false); | |
| 173 | + | assert.equal(checkMaintenance({ ...m, starts_at: "2026-10-04T01:00Z", ends_at: "2026-10-04T02:00Z" }, KNOWN, NOW).ok, false); | |
| 174 | + | assert.equal(checkMaintenance({ ...m, ends_at: "2026-10-10T03:00Z" }, KNOWN, NOW).ok, false); | |
| 175 | + | assert.equal(checkMaintenance({ ...m, components: [] }, KNOWN, NOW).ok, false); | |
| 176 | + | assert.equal(checkMaintenanceChange({ action: "update", message: "", by: "x" }).ok, false); | |
| 177 | + | assert.ok(checkMaintenanceChange({ action: "cancel", message: "", by: "x" }).ok); | |
| 178 | + | }); | |
| 179 | + | ||
| 180 | + | test("postmortems: published only with a summary and a cause; the draft is filled from the incident", () => { | |
| 181 | + | const pm = checkPostmortem({ summary: "A deploy broke pushes.", impact: "", timeline: "", root_cause: "", went_well: "", went_badly: "", action_items: "", by: "x" }); | |
| 182 | + | assert.ok(pm.ok); | |
| 183 | + | assert.match(postmortemReady(pm.value) ?? "", /caused/); | |
| 184 | + | assert.equal(postmortemReady({ ...pm.value, root_cause: "A missing index." }), null); | |
| 185 | + | ||
| 186 | + | const timeline = [ | |
| 187 | + | { id: "1", at: "2026-10-05T11:35:00Z", by: "x", kind: "declared" as const, public: false, status: null, text: "Declared SEV2.", notified: null }, | |
| 188 | + | { id: "2", at: "2026-10-05T11:36:00Z", by: "x", kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged.", notified: null }, | |
| 189 | + | { id: "3", at: "2026-10-05T11:40:00Z", by: "x", kind: "update" as const, public: true, status: "investigating" as const, text: "Pushes fail.\nLooking.", notified: 3 }, | |
| 190 | + | ]; | |
| 191 | + | assert.equal(timelineText(timeline), "- 5 Oct 11:35 UTC: Declared SEV2.\n- 5 Oct 11:40 UTC: Status page: Pushes fail. Looking."); | |
| 192 | + | const draft = postmortemDraft( | |
| 193 | + | { started_at: "2026-10-05T11:30:00Z", resolved_at: "2026-10-05T12:40:00Z", components: [{ key: "git", impact: "major_outage" }], durations: { to_acknowledge: 300, to_mitigate: null, to_resolve: 4200 } }, | |
| 194 | + | timeline, | |
| 195 | + | [{ id: "f", title: "Alert on push errors", owner: "ana@g1t.sh", done_at: null, created_at: "", created_by: "" }], | |
| 196 | + | names, | |
| 197 | + | ); | |
| 198 | + | assert.equal(draft.impact, "Git and repositories (major outage) affected for 1h 10m, from 5 Oct 11:30 UTC to 5 Oct 12:40 UTC."); | |
| 199 | + | assert.equal(draft.action_items, "- Alert on push errors"); | |
| 200 | + | }); | |
| 201 | + | ||
| 202 | + | test("short ids are ten unambiguous characters", () => { | |
| 203 | + | const id = shortId(); | |
| 204 | + | assert.match(id, /^[a-km-np-z2-9]{10}$/); | |
| 205 | + | assert.notEqual(shortId(), id); | |
| 206 | + | }); |
| 1 | + | /** | |
| 2 | + | * Incidents and maintenance as staff run them: checking what sudo sends, | |
| 3 | + | * and the lifecycle rules (which changes are allowed, which timestamps | |
| 4 | + | * they set, and what each writes on the timeline). No Workers imports, | |
| 5 | + | * so it is tested under Node. | |
| 6 | + | */ | |
| 7 | + | import { | |
| 8 | + | INCIDENT_SEVERITIES, | |
| 9 | + | type ComponentImpact, | |
| 10 | + | type DeclareIncident, | |
| 11 | + | type IncidentChange, | |
| 12 | + | type IncidentDurations, | |
| 13 | + | type IncidentSeverity, | |
| 14 | + | type IncidentStatus, | |
| 15 | + | type IncidentVisibility, | |
| 16 | + | type ImpactInput, | |
| 17 | + | type MaintenanceChange, | |
| 18 | + | type NewMaintenance, | |
| 19 | + | type PostmortemFields, | |
| 20 | + | type PublishIncident, | |
| 21 | + | type RolesChange, | |
| 22 | + | type TimelineKind, | |
| 23 | + | } from "@g1t/contracts/status"; | |
| 24 | + | ||
| 25 | + | import { IMPACT_WORD, INCIDENT_STATUS } from "./model.ts"; | |
| 26 | + | ||
| 27 | + | export const STATUSES: IncidentStatus[] = ["investigating", "identified", "monitoring", "resolved"]; | |
| 28 | + | export const IMPACTS: ComponentImpact[] = ["operational", "degraded", "partial_outage", "major_outage"]; | |
| 29 | + | ||
| 30 | + | export const SEVERITIES = INCIDENT_SEVERITIES; | |
| 31 | + | ||
| 32 | + | export const SEVERITY_LABEL = Object.fromEntries(SEVERITIES.map((s) => [s.value, s.label])) as Record<IncidentSeverity, string>; | |
| 33 | + | ||
| 34 | + | /** Whether subscribers are emailed by default at a severity. */ | |
| 35 | + | export function notifyByDefault(severity: IncidentSeverity): boolean { | |
| 36 | + | return SEVERITIES.find((s) => s.value === severity)?.notify ?? false; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export const MAX_TITLE = 120; | |
| 40 | + | export const MAX_MESSAGE = 4000; | |
| 41 | + | export const MAX_SECTION = 20000; | |
| 42 | + | /** How long maintenance may be scheduled ahead, and how long a window may be. */ | |
| 43 | + | export const MAX_AHEAD_DAYS = 180; | |
| 44 | + | export const MAX_WINDOW_HOURS = 72; | |
| 45 | + | ||
| 46 | + | export type Checked<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 47 | + | ||
| 48 | + | const fail = (error: string): { ok: false; error: string } => ({ ok: false, error }); | |
| 49 | + | ||
| 50 | + | function clean(text: unknown): string { | |
| 51 | + | return typeof text === "string" ? text.replace(/\s+/g, " ").trim() : ""; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** A message keeps its paragraphs; runs of spaces within a line are folded. */ | |
| 55 | + | export function cleanMessage(text: unknown): string { | |
| 56 | + | if (typeof text !== "string") return ""; | |
| 57 | + | return text | |
| 58 | + | .replace(/\r\n?/g, "\n") | |
| 59 | + | .split("\n") | |
| 60 | + | .map((line) => line.replace(/[ \t]+/g, " ").trimEnd()) | |
| 61 | + | .join("\n") | |
| 62 | + | .replace(/\n{3,}/g, "\n\n") | |
| 63 | + | .trim(); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,}$/i; | |
| 67 | + | ||
| 68 | + | /** A role's holder: a staff email, lowercased, or null for nobody. */ | |
| 69 | + | function role(raw: unknown, name: string): Checked<string | null> { | |
| 70 | + | const value = clean(raw).toLowerCase(); | |
| 71 | + | if (!value) return { ok: true, value: null }; | |
| 72 | + | if (!EMAIL.test(value)) return fail(`The ${name} is an email address.`); | |
| 73 | + | return { ok: true, value }; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | function startTime(raw: unknown, now: Date): Checked<string | null> { | |
| 77 | + | if (raw == null || raw === "") return { ok: true, value: null }; | |
| 78 | + | const at = Date.parse(String(raw)); | |
| 79 | + | if (Number.isNaN(at)) return fail("The start time is not a date."); | |
| 80 | + | if (at > now.getTime() + 60_000) return fail("The start time is in the future."); | |
| 81 | + | if (at < now.getTime() - 90 * 24 * 60 * 60 * 1000) return fail("The start time is over 90 days ago."); | |
| 82 | + | return { ok: true, value: new Date(at).toISOString() }; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | function title(raw: unknown): Checked<string> { | |
| 86 | + | const value = clean(raw); | |
| 87 | + | if (!value) return fail("Give it a title."); | |
| 88 | + | if (value.length > MAX_TITLE) return fail(`Keep the title under ${MAX_TITLE} characters.`); | |
| 89 | + | return { ok: true, value }; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | function by(raw: unknown): Checked<string> { | |
| 93 | + | const value = clean(raw); | |
| 94 | + | return value ? { ok: true, value } : fail("Who is making the change is missing."); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** Parts and their impact: known parts, each once, the last one given winning. */ | |
| 98 | + | export function checkImpacts(raw: unknown, known: string[]): Checked<ImpactInput[]> { | |
| 99 | + | if (!Array.isArray(raw)) return { ok: true, value: [] }; | |
| 100 | + | const byKey = new Map<string, ComponentImpact>(); | |
| 101 | + | for (const item of raw) { | |
| 102 | + | const key = String((item as ImpactInput)?.key ?? ""); | |
| 103 | + | const impact = (item as ImpactInput)?.impact; | |
| 104 | + | if (!known.includes(key)) return fail(`Unknown part: ${key || "(none)"}.`); | |
| 105 | + | if (!IMPACTS.includes(impact)) return fail(`Choose an impact for ${key}.`); | |
| 106 | + | byKey.set(key, impact); | |
| 107 | + | } | |
| 108 | + | return { ok: true, value: [...byKey].map(([key, impact]) => ({ key, impact })) }; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | export function checkDeclare(input: unknown, known: string[], now = new Date()): Checked<DeclareIncident & { started_at: string | null }> { | |
| 112 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 113 | + | const t = title(raw.title); | |
| 114 | + | if (!t.ok) return t; | |
| 115 | + | const severity = raw.severity as IncidentSeverity; | |
| 116 | + | if (!SEVERITY_LABEL[severity]) return fail("Choose a severity."); | |
| 117 | + | const status = (raw.status ?? "investigating") as IncidentStatus; | |
| 118 | + | if (!STATUSES.includes(status) || status === "resolved") return fail("A new incident is investigating, identified or monitoring."); | |
| 119 | + | const impacts = checkImpacts(raw.components, known); | |
| 120 | + | if (!impacts.ok) return impacts; | |
| 121 | + | const affected = impacts.value.filter((c) => c.impact !== "operational"); | |
| 122 | + | if (affected.length === 0) return fail("Choose at least one part it affects, and how."); | |
| 123 | + | const message = cleanMessage(raw.message); | |
| 124 | + | if (!message) return fail("Write the first update: what people notice, in a sentence or two."); | |
| 125 | + | if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`); | |
| 126 | + | const commander = role(raw.commander, "incident commander"); | |
| 127 | + | if (!commander.ok) return commander; | |
| 128 | + | const communications = role(raw.communications, "communications lead"); | |
| 129 | + | if (!communications.ok) return communications; | |
| 130 | + | const who = by(raw.by); | |
| 131 | + | if (!who.ok) return who; | |
| 132 | + | const started = startTime(raw.started_at, now); | |
| 133 | + | if (!started.ok) return started; | |
| 134 | + | return { | |
| 135 | + | ok: true, | |
| 136 | + | value: { | |
| 137 | + | title: t.value, | |
| 138 | + | severity, | |
| 139 | + | status, | |
| 140 | + | components: affected, | |
| 141 | + | message, | |
| 142 | + | started_at: started.value, | |
| 143 | + | commander: commander.value, | |
| 144 | + | communications: communications.value, | |
| 145 | + | notify: raw.notify === true, | |
| 146 | + | by: who.value, | |
| 147 | + | }, | |
| 148 | + | }; | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | export function checkChange(input: unknown, known: string[]): Checked<IncidentChange> { | |
| 152 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 153 | + | const message = cleanMessage(raw.message); | |
| 154 | + | if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`); | |
| 155 | + | const status = raw.status == null || raw.status === "" ? null : (raw.status as IncidentStatus); | |
| 156 | + | if (status && !STATUSES.includes(status)) return fail("Choose a status."); | |
| 157 | + | const severity = raw.severity == null || raw.severity === "" ? null : (raw.severity as IncidentSeverity); | |
| 158 | + | if (severity && !SEVERITY_LABEL[severity]) return fail("Choose a severity."); | |
| 159 | + | const impacts = raw.impacts == null ? { ok: true as const, value: null } : checkImpacts(raw.impacts, known); | |
| 160 | + | if (!impacts.ok) return impacts; | |
| 161 | + | const who = by(raw.by); | |
| 162 | + | if (!who.ok) return who; | |
| 163 | + | const isPublic = raw.public === true; | |
| 164 | + | if (isPublic && !message) return fail("Write the update the status page will show."); | |
| 165 | + | return { | |
| 166 | + | ok: true, | |
| 167 | + | value: { public: isPublic, message, status, severity, impacts: impacts.value, notify: isPublic && raw.notify === true, by: who.value }, | |
| 168 | + | }; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | export function checkRoles(input: unknown): Checked<RolesChange> { | |
| 172 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 173 | + | const commander = role(raw.commander, "incident commander"); | |
| 174 | + | if (!commander.ok) return commander; | |
| 175 | + | const communications = role(raw.communications, "communications lead"); | |
| 176 | + | if (!communications.ok) return communications; | |
| 177 | + | const who = by(raw.by); | |
| 178 | + | if (!who.ok) return who; | |
| 179 | + | return { ok: true, value: { commander: commander.value, communications: communications.value, by: who.value } }; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | export function checkPublish(input: unknown): Checked<PublishIncident> { | |
| 183 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 184 | + | let name: string | null = null; | |
| 185 | + | if (raw.title != null && raw.title !== "") { | |
| 186 | + | const t = title(raw.title); | |
| 187 | + | if (!t.ok) return t; | |
| 188 | + | name = t.value; | |
| 189 | + | } | |
| 190 | + | const message = cleanMessage(raw.message); | |
| 191 | + | if (!message) return fail("Write the first update the status page will show."); | |
| 192 | + | if (message.length > MAX_MESSAGE) return fail(`Keep the update under ${MAX_MESSAGE} characters.`); | |
| 193 | + | const who = by(raw.by); | |
| 194 | + | if (!who.ok) return who; | |
| 195 | + | return { ok: true, value: { title: name, message, notify: raw.notify === true, by: who.value } }; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | export function checkFollowUp(input: unknown): Checked<{ title: string; owner: string | null; by: string }> { | |
| 199 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 200 | + | const name = clean(raw.title); | |
| 201 | + | if (!name) return fail("Say what needs doing."); | |
| 202 | + | if (name.length > 200) return fail("Keep a follow-up under 200 characters."); | |
| 203 | + | const owner = role(raw.owner, "owner"); | |
| 204 | + | if (!owner.ok) return owner; | |
| 205 | + | const who = by(raw.by); | |
| 206 | + | if (!who.ok) return who; | |
| 207 | + | return { ok: true, value: { title: name, owner: owner.value, by: who.value } }; | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | export const POSTMORTEM_FIELDS: (keyof PostmortemFields)[] = ["summary", "impact", "timeline", "root_cause", "went_well", "went_badly", "action_items"]; | |
| 211 | + | ||
| 212 | + | export function checkPostmortem(input: unknown): Checked<PostmortemFields & { by: string }> { | |
| 213 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 214 | + | const fields = {} as PostmortemFields; | |
| 215 | + | for (const key of POSTMORTEM_FIELDS) { | |
| 216 | + | const value = cleanMessage(raw[key]); | |
| 217 | + | if (value.length > MAX_SECTION) return fail(`Keep each section under ${MAX_SECTION} characters.`); | |
| 218 | + | fields[key] = value; | |
| 219 | + | } | |
| 220 | + | const who = by(raw.by); | |
| 221 | + | if (!who.ok) return who; | |
| 222 | + | return { ok: true, value: { ...fields, by: who.value } }; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /** A postmortem can be published once it says what happened and why. */ | |
| 226 | + | export function postmortemReady(p: PostmortemFields): string | null { | |
| 227 | + | if (!p.summary) return "Write a summary before publishing."; | |
| 228 | + | if (!p.root_cause) return "Say what caused it before publishing."; | |
| 229 | + | return null; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | export function checkMaintenance(input: unknown, known: string[], now = new Date()): Checked<NewMaintenance> { | |
| 233 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 234 | + | const t = title(raw.title); | |
| 235 | + | if (!t.ok) return t; | |
| 236 | + | const message = cleanMessage(raw.message); | |
| 237 | + | if (!message) return fail("Say what will happen and what people will notice."); | |
| 238 | + | if (message.length > MAX_MESSAGE) return fail(`Keep the message under ${MAX_MESSAGE} characters.`); | |
| 239 | + | const components = Array.isArray(raw.components) ? [...new Set(raw.components.map(String))] : []; | |
| 240 | + | const unknown = components.filter((c) => !known.includes(c)); | |
| 241 | + | if (unknown.length) return fail(`Unknown parts: ${unknown.join(", ")}.`); | |
| 242 | + | if (components.length === 0) return fail("Choose at least one part the work affects."); | |
| 243 | + | const starts = Date.parse(String(raw.starts_at ?? "")); | |
| 244 | + | const ends = Date.parse(String(raw.ends_at ?? "")); | |
| 245 | + | if (Number.isNaN(starts) || Number.isNaN(ends)) return fail("Give the window's start and end."); | |
| 246 | + | if (ends <= starts) return fail("The window ends after it starts."); | |
| 247 | + | if (ends <= now.getTime()) return fail("The window is already over."); | |
| 248 | + | if (starts > now.getTime() + MAX_AHEAD_DAYS * 86_400_000) return fail(`Schedule maintenance at most ${MAX_AHEAD_DAYS} days ahead.`); | |
| 249 | + | if (ends - starts > MAX_WINDOW_HOURS * 3_600_000) return fail(`Keep a window under ${MAX_WINDOW_HOURS} hours.`); | |
| 250 | + | const who = by(raw.by); | |
| 251 | + | if (!who.ok) return who; | |
| 252 | + | return { | |
| 253 | + | ok: true, | |
| 254 | + | value: { | |
| 255 | + | title: t.value, | |
| 256 | + | message, | |
| 257 | + | components, | |
| 258 | + | starts_at: new Date(starts).toISOString(), | |
| 259 | + | ends_at: new Date(ends).toISOString(), | |
| 260 | + | notify: raw.notify === true, | |
| 261 | + | by: who.value, | |
| 262 | + | }, | |
| 263 | + | }; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | export function checkMaintenanceChange(input: unknown): Checked<MaintenanceChange> { | |
| 267 | + | const raw = (input ?? {}) as Record<string, unknown>; | |
| 268 | + | const action = raw.action as MaintenanceChange["action"]; | |
| 269 | + | if (!["update", "start", "complete", "cancel"].includes(action)) return fail("Choose what to do."); | |
| 270 | + | const message = cleanMessage(raw.message); | |
| 271 | + | if (action === "update" && !message) return fail("Write the update."); | |
| 272 | + | if (message.length > MAX_MESSAGE) return fail(`Keep the message under ${MAX_MESSAGE} characters.`); | |
| 273 | + | const who = by(raw.by); | |
| 274 | + | if (!who.ok) return who; | |
| 275 | + | return { ok: true, value: { action, message, notify: raw.notify === true, by: who.value } }; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // --- Lifecycle ------------------------------------------------------------------- | |
| 279 | + | ||
| 280 | + | /** What the lifecycle reads and changes on an incident. */ | |
| 281 | + | export type IncidentFacts = { | |
| 282 | + | status: IncidentStatus; | |
| 283 | + | severity: IncidentSeverity; | |
| 284 | + | visibility: IncidentVisibility; | |
| 285 | + | components: ImpactInput[]; | |
| 286 | + | started_at: string; | |
| 287 | + | acknowledged_at: string | null; | |
| 288 | + | mitigated_at: string | null; | |
| 289 | + | resolved_at: string | null; | |
| 290 | + | commander: string | null; | |
| 291 | + | communications: string | null; | |
| 292 | + | }; | |
| 293 | + | ||
| 294 | + | /** A line the change writes on the timeline. */ | |
| 295 | + | export type Entry = { kind: TimelineKind; public: boolean; status: IncidentStatus | null; text: string }; | |
| 296 | + | ||
| 297 | + | const ORDER: Record<IncidentStatus, number> = { investigating: 0, identified: 1, monitoring: 2, resolved: 3 }; | |
| 298 | + | ||
| 299 | + | /** | |
| 300 | + | * Applies an update, a note, or changes. The rules: | |
| 301 | + | * | |
| 302 | + | * - A dismissed incident is closed for good. | |
| 303 | + | * - A draft takes notes and changes, but nothing public until it is published. | |
| 304 | + | * - On a published incident, a status change is said publicly: the status page | |
| 305 | + | * shows each status with words. | |
| 306 | + | * - Reaching monitoring (or resolved) marks it mitigated; resolved marks it | |
| 307 | + | * resolved; leaving resolved reopens it and clears that. | |
| 308 | + | * - The first change by a person acknowledges a detected incident. | |
| 309 | + | */ | |
| 310 | + | export function applyChange( | |
| 311 | + | facts: IncidentFacts, | |
| 312 | + | change: IncidentChange, | |
| 313 | + | now: Date, | |
| 314 | + | names: Map<string, string> = new Map(), | |
| 315 | + | ): Checked<{ next: IncidentFacts; entries: Entry[] }> { | |
| 316 | + | if (facts.visibility === "dismissed") return fail("This draft was dismissed; declare a new incident instead."); | |
| 317 | + | if (change.public && facts.visibility === "draft") return fail("Publish the draft before posting public updates."); | |
| 318 | + | const at = now.toISOString(); | |
| 319 | + | const next: IncidentFacts = { ...facts, components: facts.components.map((c) => ({ ...c })) }; | |
| 320 | + | const entries: Entry[] = []; | |
| 321 | + | ||
| 322 | + | const status = change.status && change.status !== facts.status ? change.status : null; | |
| 323 | + | if (status && facts.visibility === "public" && !change.public) { | |
| 324 | + | return fail("Changing the status of a published incident is a public update: say what changed for the status page."); | |
| 325 | + | } | |
| 326 | + | if (!status && !change.severity && !change.impacts?.length && !change.message) return fail("Nothing to post: write an update or change something."); | |
| 327 | + | ||
| 328 | + | if (!next.acknowledged_at) { | |
| 329 | + | next.acknowledged_at = at; | |
| 330 | + | entries.push({ kind: "acknowledged", public: false, status: null, text: "Acknowledged." }); | |
| 331 | + | } | |
| 332 | + | if (change.severity && change.severity !== facts.severity) { | |
| 333 | + | entries.push({ | |
| 334 | + | kind: "severity", | |
| 335 | + | public: false, | |
| 336 | + | status: null, | |
| 337 | + | text: `Severity ${SEVERITY_LABEL[facts.severity]} → ${SEVERITY_LABEL[change.severity]}.`, | |
| 338 | + | }); | |
| 339 | + | next.severity = change.severity; | |
| 340 | + | } | |
| 341 | + | for (const { key, impact } of change.impacts ?? []) { | |
| 342 | + | const index = next.components.findIndex((c) => c.key === key); | |
| 343 | + | const before = index >= 0 ? next.components[index]!.impact : "operational"; | |
| 344 | + | if (before === impact) continue; | |
| 345 | + | if (index >= 0) next.components[index]!.impact = impact; | |
| 346 | + | else next.components.push({ key, impact }); | |
| 347 | + | entries.push({ kind: "impact", public: false, status: null, text: `${names.get(key) ?? key}: ${IMPACT_WORD[before]} → ${IMPACT_WORD[impact]}.` }); | |
| 348 | + | } | |
| 349 | + | if (status) { | |
| 350 | + | const reopening = facts.status === "resolved"; | |
| 351 | + | entries.push({ | |
| 352 | + | kind: "status", | |
| 353 | + | public: false, | |
| 354 | + | status, | |
| 355 | + | text: reopening ? `Reopened: ${INCIDENT_STATUS[status]}.` : `${INCIDENT_STATUS[facts.status]} → ${INCIDENT_STATUS[status]}.`, | |
| 356 | + | }); | |
| 357 | + | next.status = status; | |
| 358 | + | if (ORDER[status] >= ORDER.monitoring && !next.mitigated_at) next.mitigated_at = at; | |
| 359 | + | if (status === "resolved") next.resolved_at = at; | |
| 360 | + | if (reopening) next.resolved_at = null; | |
| 361 | + | } | |
| 362 | + | if (change.message) { | |
| 363 | + | entries.push({ kind: change.public ? "update" : "note", public: change.public, status: change.public ? next.status : null, text: change.message }); | |
| 364 | + | } | |
| 365 | + | return { ok: true, value: { next, entries } }; | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /** A change of roles, and the lines it writes. */ | |
| 369 | + | export function applyRoles(facts: IncidentFacts, change: RolesChange, now: Date): { next: IncidentFacts; entries: Entry[] } { | |
| 370 | + | const next = { ...facts }; | |
| 371 | + | const entries: Entry[] = []; | |
| 372 | + | if (!next.acknowledged_at) { | |
| 373 | + | next.acknowledged_at = now.toISOString(); | |
| 374 | + | entries.push({ kind: "acknowledged", public: false, status: null, text: "Acknowledged." }); | |
| 375 | + | } | |
| 376 | + | const say = (who: string | null) => who ?? "nobody"; | |
| 377 | + | if (change.commander !== facts.commander) { | |
| 378 | + | entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${say(change.commander)}.` }); | |
| 379 | + | next.commander = change.commander; | |
| 380 | + | } | |
| 381 | + | if (change.communications !== facts.communications) { | |
| 382 | + | entries.push({ kind: "role", public: false, status: null, text: `Communications: ${say(change.communications)}.` }); | |
| 383 | + | next.communications = change.communications; | |
| 384 | + | } | |
| 385 | + | return { next, entries }; | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | /** Seconds from the impact starting to each milestone. */ | |
| 389 | + | export function durations(i: Pick<IncidentFacts, "started_at" | "acknowledged_at" | "mitigated_at" | "resolved_at">): IncidentDurations { | |
| 390 | + | const start = Date.parse(i.started_at); | |
| 391 | + | const since = (at: string | null) => (at ? Math.max(0, Math.round((Date.parse(at) - start) / 1000)) : null); | |
| 392 | + | return { to_acknowledge: since(i.acknowledged_at), to_mitigate: since(i.mitigated_at), to_resolve: since(i.resolved_at) }; | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /** "45s", "12m", "2h 05m", "3d 4h". */ | |
| 396 | + | export function duration(seconds: number | null): string { | |
| 397 | + | if (seconds == null) return "—"; | |
| 398 | + | if (seconds < 60) return `${seconds}s`; | |
| 399 | + | const minutes = Math.floor(seconds / 60); | |
| 400 | + | if (minutes < 60) return `${minutes}m`; | |
| 401 | + | const hours = Math.floor(minutes / 60); | |
| 402 | + | if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; | |
| 403 | + | return `${Math.floor(hours / 24)}d ${hours % 24}h`; | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | /** A short random id for a permalink: ten lowercase letters and digits. */ | |
| 407 | + | export function shortId(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string { | |
| 408 | + | const alphabet = "abcdefghijkmnpqrstuvwxyz23456789"; | |
| 409 | + | return [...random(10)].map((b) => alphabet[b % alphabet.length]).join(""); | |
| 410 | + | } |
| 1 | + | /** | |
| 2 | + | * status.g1t.sh: whether each part of g1t is working, how it has done over | |
| 3 | + | * 90 days, and what staff have said about incidents and maintenance. | |
| 4 | + | * | |
| 5 | + | * A Worker of its own, apart from the site, so it stays up when g1t does | |
| 6 | + | * not. Every minute a cron checks each part over the public internet, as | |
| 7 | + | * people reach it, and keeps the result in D1. The same run moves planned | |
| 8 | + | * maintenance along, and drafts an incident for staff when a part keeps | |
| 9 | + | * failing (detect.ts). Pages are drawn from what is kept, never by | |
| 10 | + | * checking on the spot, and kept at the edge for 30 seconds. | |
| 11 | + | * | |
| 12 | + | * Staff run incidents from sudo, through the `StatusAdmin` entrypoint, | |
| 13 | + | * which only a service binding reaches. Every change there is audited. | |
| 14 | + | * | |
| 15 | + | * GET / the page | |
| 16 | + | * GET /status.json the same as JSON (snake_case, CORS open) | |
| 17 | + | * GET /badge.svg a small badge | |
| 18 | + | * GET /incidents/<id> an incident's updates and postmortem | |
| 19 | + | * GET /maintenance/<id> a maintenance window's updates | |
| 20 | + | * GET /history the last 12 months, by month | |
| 21 | + | * GET /feed.xml, /feed.json every public update, newest first | |
| 22 | + | * GET /subscribe subscribing by email | |
| 23 | + | * POST /subscribe asks for a subscription: a confirmation email | |
| 24 | + | * GET|POST /subscribe/confirm?token= confirms (GET shows a button: link scanners must not confirm) | |
| 25 | + | * GET|POST /unsubscribe?token= leaves (POST also takes RFC 8058 one-click) | |
| 26 | + | */ | |
| 27 | + | import { WorkerEntrypoint } from "cloudflare:workers"; | |
| 28 | + | import { | |
| 29 | + | type AdminIncident, | |
| 30 | + | type AdminIncidentDetail, | |
| 31 | + | type AdminMaintenance, | |
| 32 | + | type DeclareIncident, | |
| 33 | + | type FollowUp, | |
| 34 | + | type IncidentChange, | |
| 35 | + | type MaintenanceChange, | |
| 36 | + | type NewMaintenance, | |
| 37 | + | type Postmortem, | |
| 38 | + | type PostmortemFields, | |
| 39 | + | type PublishIncident, | |
| 40 | + | type Result, | |
| 41 | + | type RolesChange, | |
| 42 | + | type StatusAdminApi, | |
| 43 | + | type StatusAuditEntry, | |
| 44 | + | type StatusBoard, | |
| 45 | + | billingClient, | |
| 46 | + | fail, | |
| 47 | + | ok, | |
| 48 | + | } from "@g1t/contracts"; | |
| 49 | + | import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2"; | |
| 50 | + | import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2"; | |
| 51 | + | import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2"; | |
| 52 | + | ||
| 53 | + | import { type Targets, components } from "./components.ts"; | |
| 54 | + | import { DETECT_AFTER, detect, detectedImpact, draftTitle } from "./detect.ts"; | |
| 55 | + | import { type EmailBinding, type Sender, alertLetter, bindingSender, confirmLetter, render as renderMail, unsubscribeHeaders, updateLetter } from "./email.ts"; | |
| 56 | + | import { atom, feedItems, jsonFeed } from "./feed.ts"; | |
| 57 | + | import { | |
| 58 | + | type Entry, | |
| 59 | + | applyChange, | |
| 60 | + | applyRoles, | |
| 61 | + | checkChange, | |
| 62 | + | checkDeclare, | |
| 63 | + | checkFollowUp, | |
| 64 | + | checkMaintenance, | |
| 65 | + | checkMaintenanceChange, | |
| 66 | + | checkPostmortem, | |
| 67 | + | checkPublish, | |
| 68 | + | checkRoles, | |
| 69 | + | postmortemReady, | |
| 70 | + | SEVERITY_LABEL, | |
| 71 | + | shortId, | |
| 72 | + | } from "./incidents.ts"; | |
| 73 | + | import { INCIDENT_STATUS, type PageModel, buildPage, classify, underMaintenance } from "./model.ts"; | |
| 74 | + | import { stamp } from "./postmortem.ts"; | |
| 75 | + | import { runCheck } from "./probe.ts"; | |
| 76 | + | import { | |
| 77 | + | FAVICON, | |
| 78 | + | SCRIPT, | |
| 79 | + | type PageOptions, | |
| 80 | + | renderBadge, | |
| 81 | + | renderHistory, | |
| 82 | + | renderIncident, | |
| 83 | + | renderMaintenance, | |
| 84 | + | renderMessage, | |
| 85 | + | renderPage, | |
| 86 | + | renderSubscribe, | |
| 87 | + | } from "./render.ts"; | |
| 88 | + | import { | |
| 89 | + | type Observation, | |
| 90 | + | addFollowUp, | |
| 91 | + | addSystemLines, | |
| 92 | + | auditLog, | |
| 93 | + | board, | |
| 94 | + | confirmSubscription, | |
| 95 | + | createIncident, | |
| 96 | + | dueMaintenance, | |
| 97 | + | facts, | |
| 98 | + | incidentDetail, | |
| 99 | + | load, | |
| 100 | + | loadHistory, | |
| 101 | + | loadPublicIncident, | |
| 102 | + | loadPublicMaintenance, | |
| 103 | + | loadStreaks, | |
| 104 | + | maintenanceById, | |
| 105 | + | maintenanceUrl, | |
| 106 | + | maintenanceUpdate, | |
| 107 | + | openCount, | |
| 108 | + | openRefs, | |
| 109 | + | publishPostmortem, | |
| 110 | + | recipients, | |
| 111 | + | record, | |
| 112 | + | requestSubscription, | |
| 113 | + | saveIncident, | |
| 114 | + | savePostmortem, | |
| 115 | + | saveStreaks, | |
| 116 | + | scheduleMaintenance, | |
| 117 | + | setFollowUp, | |
| 118 | + | unsubscribe, | |
| 119 | + | } from "./store.ts"; | |
| 120 | + | import { CONFIRM_TTL_MS, RESEND_AFTER_MS, chosenParts, hashToken, newToken, normalizeEmail, readUnsubscribeToken, unsubscribeToken } from "./subscribers.ts"; | |
| 121 | + | ||
| 122 | + | export interface Env extends Partial<Targets> { | |
| 123 | + | DB: D1Database; | |
| 124 | + | /** Billing, for reading its price book. Optional: without it, billing is not listed. */ | |
| 125 | + | BILLING?: Fetcher; | |
| 126 | + | /** Where help is. */ | |
| 127 | + | SUPPORT_URL?: string; | |
| 128 | + | /** | |
| 129 | + | * The site's address as people's browsers reach it, for the page's links, | |
| 130 | + | * when the checks reach it by another (self-hosted: `http://g1t:8787` | |
| 131 | + | * inside Compose). SITE_URL when empty. | |
| 132 | + | */ | |
| 133 | + | PUBLIC_SITE_URL?: string; | |
| 134 | + | /** The page's share card; empty for none. */ | |
| 135 | + | OG_IMAGE?: string; | |
| 136 | + | /** This page's own address, for links in email and feeds made outside a request. */ | |
| 137 | + | STATUS_URL?: string; | |
| 138 | + | /** Where sudo is, for the staff alert's link. */ | |
| 139 | + | SUDO_URL?: string; | |
| 140 | + | /** Who hears about detected drafts. Empty sends none. */ | |
| 141 | + | STATUS_ALERT_EMAIL?: string; | |
| 142 | + | /** The From of every email. */ | |
| 143 | + | STATUS_FROM?: string; | |
| 144 | + | /** Signs unsubscribe links (a secret). Without it, email subscriptions are off; the feeds still work. */ | |
| 145 | + | STATUS_SECRET?: string; | |
| 146 | + | /** Cloudflare Email Sending (`send_email`). Without it, nothing is emailed. */ | |
| 147 | + | EMAIL?: EmailBinding; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /** How long the edge keeps a page or the JSON. */ | |
| 151 | + | const CACHE_SECONDS = 30; | |
| 152 | + | /** Older than this, a visit asks for a round of checks too (a missed cron, or `wrangler dev`). */ | |
| 153 | + | const BEHIND_MS = 3 * 60 * 1000; | |
| 154 | + | /** How many subscribers one update emails at most, within a Worker's limits. */ | |
| 155 | + | const MAX_RECIPIENTS = 900; | |
| 156 | + | ||
| 157 | + | function parts(env: Env) { | |
| 158 | + | return components(env, env.BILLING != null); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | function names(env: Env): Map<string, string> { | |
| 162 | + | return new Map(parts(env).map((p) => [p.key, p.name])); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /** This page's address: the request's own, or STATUS_URL outside a request. */ | |
| 166 | + | function originOf(env: Env, url?: URL): string { | |
| 167 | + | return (url?.origin ?? env.STATUS_URL ?? "https://status.g1t.sh").replace(/\/+$/, ""); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | function sender(env: Env): Sender | null { | |
| 171 | + | return bindingSender(env.EMAIL, env.STATUS_FROM || undefined); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /** Whether subscribers can sign up: a way to send, and a secret to sign their links. */ | |
| 175 | + | function emailOn(env: Env): boolean { | |
| 176 | + | return sender(env) != null && !!env.STATUS_SECRET; | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /** One round of checks, kept. Parts under maintenance are checked but not tallied. */ | |
| 180 | + | export async function checkAll(env: Env, now = new Date()): Promise<Observation[]> { | |
| 181 | + | const billing = env.BILLING; | |
| 182 | + | const list = parts(env); | |
| 183 | + | const observations = await Promise.all( | |
| 184 | + | list.map(async (info): Promise<Observation> => { | |
| 185 | + | const result = await runCheck(info.check, { | |
| 186 | + | fetch: (url, init) => fetch(url, init), | |
| 187 | + | billing: billing ? () => billingClient(billing).prices() : null, | |
| 188 | + | }); | |
| 189 | + | const { state, detail } = classify(result); | |
| 190 | + | return { component: info.key, state, detail, latency_ms: result ? Math.round(result.ms) : null }; | |
| 191 | + | }), | |
| 192 | + | ); | |
| 193 | + | const { maintenance } = await load(env.DB, now, originOf(env)); | |
| 194 | + | await record(env.DB, observations, now, underMaintenance(maintenance, now)); | |
| 195 | + | return observations; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // --- Email --------------------------------------------------------------------------- | |
| 199 | + | ||
| 200 | + | /** | |
| 201 | + | * Emails confirmed subscribers who want news about `about`, in the | |
| 202 | + | * background. Returns how many it will email, or null when email is off. | |
| 203 | + | */ | |
| 204 | + | async function notify( | |
| 205 | + | env: Env, | |
| 206 | + | ctx: { waitUntil(p: Promise<unknown>): void }, | |
| 207 | + | about: string[], | |
| 208 | + | mail: { heading: string; text: string; url: string }, | |
| 209 | + | ): Promise<number | null> { | |
| 210 | + | const send = sender(env); | |
| 211 | + | const secret = env.STATUS_SECRET; | |
| 212 | + | if (!send || !secret) return null; | |
| 213 | + | const list = (await recipients(env.DB, about)).slice(0, MAX_RECIPIENTS); | |
| 214 | + | const origin = originOf(env); | |
| 215 | + | const affects = about.map((k) => names(env).get(k) ?? k); | |
| 216 | + | ctx.waitUntil( | |
| 217 | + | (async () => { | |
| 218 | + | let failed = 0; | |
| 219 | + | for (let i = 0; i < list.length; i += 6) { | |
| 220 | + | await Promise.all( | |
| 221 | + | list.slice(i, i + 6).map(async (r) => { | |
| 222 | + | const link = `${origin}/unsubscribe?token=${encodeURIComponent(await unsubscribeToken(secret, r.id))}`; | |
| 223 | + | const { text, html } = renderMail(updateLetter({ heading: mail.heading, text: mail.text, url: mail.url, affects, unsubscribe: link })); | |
| 224 | + | await send.send({ to: r.email, subject: mail.heading, text, html, headers: unsubscribeHeaders(link) }).catch(() => void (failed += 1)); | |
| 225 | + | }), | |
| 226 | + | ); | |
| 227 | + | } | |
| 228 | + | console.log(JSON.stringify({ event: "status.notified", sent: list.length - failed, failed })); | |
| 229 | + | })(), | |
| 230 | + | ); | |
| 231 | + | return list.length; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // --- The cron: detection and maintenance -------------------------------------------------- | |
| 235 | + | ||
| 236 | + | async function afterChecks(env: Env, ctx: { waitUntil(p: Promise<unknown>): void }, observations: Observation[], now: Date): Promise<void> { | |
| 237 | + | const origin = originOf(env); | |
| 238 | + | const named = names(env); | |
| 239 | + | // Maintenance whose window opened or closed. | |
| 240 | + | for (const m of await dueMaintenance(env.DB, now, origin)) { | |
| 241 | + | const ended = Date.parse(m.ends_at) <= now.getTime(); | |
| 242 | + | const text = ended ? "The maintenance is complete." : "The maintenance has begun."; | |
| 243 | + | const notified = m.notify ? await notify(env, ctx, m.components, { heading: `${ended ? "Completed" : "In progress"}: ${m.title}`, text, url: m.url }) : null; | |
| 244 | + | await maintenanceUpdate(env.DB, m.id, ended ? "completed" : "in_progress", text, "status", notified, now, { | |
| 245 | + | action: ended ? "maintenance_completed" : "maintenance_started", | |
| 246 | + | detail: `${m.title} (on schedule)`, | |
| 247 | + | }); | |
| 248 | + | } | |
| 249 | + | // Detection. | |
| 250 | + | const [streaks, open, page] = await Promise.all([loadStreaks(env.DB), openRefs(env.DB), load(env.DB, now, origin)]); | |
| 251 | + | const found = detect(streaks, observations, open, underMaintenance(page.maintenance, now), now); | |
| 252 | + | await saveStreaks(env.DB, found.streaks); | |
| 253 | + | const lines = [ | |
| 254 | + | ...found.failing.map((f) => ({ | |
| 255 | + | incident: f.incident, | |
| 256 | + | kind: "failing" as const, | |
| 257 | + | text: `${named.get(f.key) ?? f.key}: checks ${f.state === "down" ? "failing" : "slow"} ${DETECT_AFTER} times in a row since ${stamp(f.since)}.`, | |
| 258 | + | })), | |
| 259 | + | ...found.recovered.map((r) => ({ | |
| 260 | + | incident: r.incident, | |
| 261 | + | kind: "recovered" as const, | |
| 262 | + | text: `${named.get(r.key) ?? r.key}: answering again, after ${r.checks} failed or slow checks since ${stamp(r.since)}.`, | |
| 263 | + | })), | |
| 264 | + | ]; | |
| 265 | + | await addSystemLines(env.DB, lines, now); | |
| 266 | + | if (found.draft.length) { | |
| 267 | + | const core = new Set(parts(env).filter((p) => p.core).map((p) => p.key)); | |
| 268 | + | const title = draftTitle(found.draft.map((d) => ({ name: named.get(d.key) ?? d.key, state: d.state }))); | |
| 269 | + | const since = found.draft.map((d) => d.since).sort()[0]!; | |
| 270 | + | const id = await createIncident( | |
| 271 | + | env.DB, | |
| 272 | + | { | |
| 273 | + | title, | |
| 274 | + | severity: found.draft.some((d) => d.state === "down" && core.has(d.key)) ? "sev2" : "sev3", | |
| 275 | + | status: "investigating", | |
| 276 | + | visibility: "draft", | |
| 277 | + | source: "detected", | |
| 278 | + | components: found.draft.map((d) => ({ key: d.key, impact: detectedImpact(d.state) })), | |
| 279 | + | started_at: since, | |
| 280 | + | acknowledged_at: null, | |
| 281 | + | commander: null, | |
| 282 | + | communications: null, | |
| 283 | + | by: "status", | |
| 284 | + | }, | |
| 285 | + | [ | |
| 286 | + | { | |
| 287 | + | kind: "detected", | |
| 288 | + | public: false, | |
| 289 | + | status: null, | |
| 290 | + | text: `The checks failed ${DETECT_AFTER} times in a row: ${found.draft.map((d) => `${named.get(d.key) ?? d.key} (${d.state === "down" ? "not answering" : "slow"})`).join(", ")}. Not on the status page until it is published.`, | |
| 291 | + | }, | |
| 292 | + | ], | |
| 293 | + | now, | |
| 294 | + | { action: "incident_detected", detail: title }, | |
| 295 | + | ); | |
| 296 | + | console.warn(JSON.stringify({ event: "status.detected", id, parts: found.draft.map((d) => d.key) })); | |
| 297 | + | const to = (env.STATUS_ALERT_EMAIL ?? "").trim(); | |
| 298 | + | const send = sender(env); | |
| 299 | + | if (to && send) { | |
| 300 | + | const link = `${(env.SUDO_URL || "https://sudo.g1t.sh").replace(/\/+$/, "")}/incidents/${id}`; | |
| 301 | + | const { text, html } = renderMail(alertLetter({ title, parts: found.draft.map((d) => named.get(d.key) ?? d.key), since: stamp(since), link })); | |
| 302 | + | ctx.waitUntil(send.send({ to, subject: `[g1t status] ${title}`, text, html }).catch((e) => console.error(JSON.stringify({ event: "status.alert_failed", error: String(e) })))); | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // --- Pages ------------------------------------------------------------------------------- | |
| 308 | + | ||
| 309 | + | async function model(env: Env, now: Date, origin: string): Promise<PageModel> { | |
| 310 | + | const stored = await load(env.DB, now, origin); | |
| 311 | + | return buildPage({ | |
| 312 | + | parts: parts(env), | |
| 313 | + | current: stored.current, | |
| 314 | + | checkedAt: stored.checkedAt, | |
| 315 | + | days: stored.days, | |
| 316 | + | incidents: stored.incidents, | |
| 317 | + | maintenance: stored.maintenance, | |
| 318 | + | now, | |
| 319 | + | }); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | /** When this isolate last asked for a catch-up round, so a busy page asks once. */ | |
| 323 | + | let caughtUpAt = 0; | |
| 324 | + | ||
| 325 | + | function catchUp(env: Env, ctx: ExecutionContext, page: PageModel, now: Date) { | |
| 326 | + | const checked = page.report.checked_at ? Date.parse(page.report.checked_at) : 0; | |
| 327 | + | if (now.getTime() - checked < BEHIND_MS || now.getTime() - caughtUpAt < BEHIND_MS) return; | |
| 328 | + | caughtUpAt = now.getTime(); | |
| 329 | + | ctx.waitUntil(checkAll(env, now).catch((error) => console.error(JSON.stringify({ event: "status.catch_up_failed", error: String(error) })))); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | const PAGE_POLICY = [ | |
| 333 | + | "default-src 'none'", | |
| 334 | + | "script-src 'self'", | |
| 335 | + | "style-src 'unsafe-inline'", | |
| 336 | + | "font-src 'self'", | |
| 337 | + | "img-src 'self' data:", | |
| 338 | + | "base-uri 'none'", | |
| 339 | + | "form-action 'self'", | |
| 340 | + | "frame-ancestors 'none'", | |
| 341 | + | ].join("; "); | |
| 342 | + | ||
| 343 | + | const COMMON = { | |
| 344 | + | "x-content-type-options": "nosniff", | |
| 345 | + | "referrer-policy": "strict-origin-when-cross-origin", | |
| 346 | + | }; | |
| 347 | + | ||
| 348 | + | function edgeCache(): Cache | null { | |
| 349 | + | return (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default ?? null; | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /** A response from the edge cache, or made and kept there. */ | |
| 353 | + | async function cached(request: Request, ctx: ExecutionContext, make: () => Promise<Response>): Promise<Response> { | |
| 354 | + | const cache = edgeCache(); | |
| 355 | + | const url = new URL(request.url); | |
| 356 | + | const key = new Request(url.origin + url.pathname, { method: "GET" }); | |
| 357 | + | if (cache) { | |
| 358 | + | const hit = await cache.match(key).catch(() => undefined); | |
| 359 | + | if (hit) return hit; | |
| 360 | + | } | |
| 361 | + | const response = await make(); | |
| 362 | + | if (cache && response.ok) ctx.waitUntil(cache.put(key, response.clone()).catch(() => undefined)); | |
| 363 | + | return response; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | const FONTS: Record<string, ArrayBuffer> = { | |
| 367 | + | "/fonts/hanken-grotesk.woff2": hanken, | |
| 368 | + | "/fonts/bricolage-grotesque.woff2": bricolage, | |
| 369 | + | "/fonts/ibm-plex-mono.woff2": plexMono, | |
| 370 | + | }; | |
| 371 | + | ||
| 372 | + | function html(body: string, cacheControl: string, status = 200): Response { | |
| 373 | + | return new Response(body, { | |
| 374 | + | status, | |
| 375 | + | headers: { "content-type": "text/html; charset=utf-8", "cache-control": cacheControl, "content-security-policy": PAGE_POLICY, ...COMMON }, | |
| 376 | + | }); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | async function form(request: Request): Promise<FormData> { | |
| 380 | + | try { | |
| 381 | + | return await request.formData(); | |
| 382 | + | } catch { | |
| 383 | + | return new FormData(); | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /** Subscribing, confirming and leaving: the page's only writes. */ | |
| 388 | + | async function subscriptions(request: Request, env: Env, ctx: ExecutionContext, url: URL, options: PageOptions): Promise<Response | null> { | |
| 389 | + | const path = url.pathname; | |
| 390 | + | const noStore = "no-store"; | |
| 391 | + | const message = (title: string, text: string, status = 200, f?: { action: string; fields: Record<string, string>; button: string }) => | |
| 392 | + | html(renderMessage({ ...options, selfUrl: `${url.origin}${path}` }, { title, text, form: f }), noStore, status); | |
| 393 | + | const post = request.method === "POST"; | |
| 394 | + | ||
| 395 | + | if (path === "/subscribe" && post) { | |
| 396 | + | if (!emailOn(env)) return message("Email updates are not available", "Follow the Atom or JSON feed instead.", 503); | |
| 397 | + | const data = await form(request); | |
| 398 | + | if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way."); | |
| 399 | + | const email = normalizeEmail(data.get("email")); | |
| 400 | + | if (!email) return message("That is not an email address", "Go back and check it.", 400); | |
| 401 | + | const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key)); | |
| 402 | + | const token = newToken(); | |
| 403 | + | const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS); | |
| 404 | + | if (send) { | |
| 405 | + | const link = `${url.origin}/subscribe/confirm?token=${encodeURIComponent(token)}`; | |
| 406 | + | const { text, html: body } = renderMail(confirmLetter(link, chosen ? chosen.map((k) => names(env).get(k) ?? k) : null)); | |
| 407 | + | ctx.waitUntil(sender(env)!.send({ to: email, subject: "Confirm your subscription to g1t status", text, html: body }).catch((e) => console.error(JSON.stringify({ event: "status.confirm_failed", error: String(e) })))); | |
| 408 | + | } | |
| 409 | + | return message("Check your inbox", "If the address is right, a confirmation link is on its way. It works for 24 hours."); | |
| 410 | + | } | |
| 411 | + | if (path === "/subscribe/confirm") { | |
| 412 | + | const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : ""); | |
| 413 | + | if (!token) return message("That link is incomplete", "Copy the whole link from the email.", 400); | |
| 414 | + | if (!post) return message("Confirm your subscription", "One more step: confirm to start getting emails about incidents and maintenance.", 200, { action: "/subscribe/confirm", fields: { token }, button: "Confirm subscription" }); | |
| 415 | + | const done = await confirmSubscription(env.DB, await hashToken(token), new Date()); | |
| 416 | + | if (!done) return message("That link has expired", "Confirmation links work for 24 hours and once. Subscribe again for a new one.", 410); | |
| 417 | + | return message("You are subscribed", `${done.email} will get an email when g1t posts an incident or maintenance${done.parts ? ` affecting ${done.parts.map((k) => names(env).get(k) ?? k).join(", ")}` : ""}. Every email has a link to unsubscribe.`); | |
| 418 | + | } | |
| 419 | + | if (path === "/unsubscribe") { | |
| 420 | + | const token = url.searchParams.get("token") ?? (post ? String((await form(request)).get("token") ?? "") : ""); | |
| 421 | + | const id = env.STATUS_SECRET && token ? await readUnsubscribeToken(env.STATUS_SECRET, token) : null; | |
| 422 | + | if (!id) return message("That link is not valid", "Use the unsubscribe link at the bottom of any email from g1t status.", 400); | |
| 423 | + | if (!post) return message("Unsubscribe", "Stop getting emails from g1t status?", 200, { action: "/unsubscribe", fields: { token }, button: "Unsubscribe" }); | |
| 424 | + | await unsubscribe(env.DB, id); | |
| 425 | + | return message("You are unsubscribed", "You will not get any more emails from g1t status. You can subscribe again at any time."); | |
| 426 | + | } | |
| 427 | + | return null; | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 431 | + | const url = new URL(request.url); | |
| 432 | + | const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname; | |
| 433 | + | if (request.method === "OPTIONS" && (path === "/status.json" || path === "/feed.json")) { | |
| 434 | + | return new Response(null, { | |
| 435 | + | status: 204, | |
| 436 | + | headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, HEAD", "access-control-max-age": "86400" }, | |
| 437 | + | }); | |
| 438 | + | } | |
| 439 | + | const now = new Date(); | |
| 440 | + | const origin = originOf(env, url); | |
| 441 | + | const site = env.PUBLIC_SITE_URL || env.SITE_URL || url.origin; | |
| 442 | + | const options: PageOptions = { | |
| 443 | + | siteUrl: site, | |
| 444 | + | supportUrl: env.SUPPORT_URL || `${site}/support`, | |
| 445 | + | ogImage: env.OG_IMAGE ?? "", | |
| 446 | + | selfUrl: `${url.origin}${path === "/" ? "/" : path}`, | |
| 447 | + | now, | |
| 448 | + | email: emailOn(env), | |
| 449 | + | }; | |
| 450 | + | ||
| 451 | + | if (request.method === "POST") { | |
| 452 | + | const answer = await subscriptions(request, env, ctx, url, options); | |
| 453 | + | return answer ?? new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD", ...COMMON } }); | |
| 454 | + | } | |
| 455 | + | if (request.method !== "GET" && request.method !== "HEAD") { | |
| 456 | + | return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD, POST", ...COMMON } }); | |
| 457 | + | } | |
| 458 | + | const fresh = (cacheSeconds = CACHE_SECONDS) => `public, max-age=${cacheSeconds}`; | |
| 459 | + | const notFound = () => html(renderMessage(options, { title: "Not found", text: "There is nothing at this address." }), fresh(), 404); | |
| 460 | + | ||
| 461 | + | switch (path) { | |
| 462 | + | case "/": | |
| 463 | + | return cached(request, ctx, async () => { | |
| 464 | + | const page = await model(env, now, origin); | |
| 465 | + | catchUp(env, ctx, page, now); | |
| 466 | + | return html(renderPage(page, options), fresh()); | |
| 467 | + | }); | |
| 468 | + | case "/status.json": | |
| 469 | + | return cached(request, ctx, async () => { | |
| 470 | + | const page = await model(env, now, origin); | |
| 471 | + | catchUp(env, ctx, page, now); | |
| 472 | + | return Response.json(page.report, { headers: { "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON } }); | |
| 473 | + | }); | |
| 474 | + | case "/badge.svg": | |
| 475 | + | return cached(request, ctx, async () => { | |
| 476 | + | const page = await model(env, now, origin); | |
| 477 | + | return new Response(renderBadge(page.report.overall.state, page.report.overall.title), { | |
| 478 | + | headers: { "content-type": "image/svg+xml", "cache-control": fresh(), "access-control-allow-origin": "*", ...COMMON }, | |
| 479 | + | }); | |
| 480 | + | }); | |
| 481 | + | case "/history": | |
| 482 | + | return cached(request, ctx, async () => { | |
| 483 | + | const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)); | |
| 484 | + | const { incidents, maintenance } = await loadHistory(env.DB, since, origin); | |
| 485 | + | return html(renderHistory(incidents, maintenance, options), fresh()); | |
| 486 | + | }); | |
| 487 | + | case "/feed.xml": | |
| 488 | + | case "/feed.json": | |
| 489 | + | return cached(request, ctx, async () => { | |
| 490 | + | const { incidents, maintenance } = await loadHistory(env.DB, new Date(now.getTime() - 365 * 86_400_000), origin); | |
| 491 | + | const items = feedItems(incidents, maintenance); | |
| 492 | + | const feed = { origin, title: "g1t status", updated: now.toISOString() }; | |
| 493 | + | const headers = { "cache-control": fresh(60), "access-control-allow-origin": "*", ...COMMON }; | |
| 494 | + | return path === "/feed.xml" | |
| 495 | + | ? new Response(atom(items, feed), { headers: { "content-type": "application/atom+xml; charset=utf-8", ...headers } }) | |
| 496 | + | : Response.json(jsonFeed(items, feed), { headers: { "content-type": "application/feed+json; charset=utf-8", ...headers } }); | |
| 497 | + | }); | |
| 498 | + | case "/subscribe": | |
| 499 | + | return html(renderSubscribe(options, parts(env).map(({ key, name }) => ({ key, name }))), fresh(300)); | |
| 500 | + | case "/subscribe/confirm": | |
| 501 | + | case "/unsubscribe": | |
| 502 | + | return (await subscriptions(request, env, ctx, url, options))!; | |
| 503 | + | case "/status.js": | |
| 504 | + | return new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } }); | |
| 505 | + | case "/favicon.svg": | |
| 506 | + | case "/favicon.ico": | |
| 507 | + | return new Response(FAVICON, { headers: { "content-type": "image/svg+xml", "cache-control": fresh(86400), ...COMMON } }); | |
| 508 | + | case "/robots.txt": | |
| 509 | + | return new Response("User-agent: *\nAllow: /\nDisallow: /subscribe/confirm\nDisallow: /unsubscribe\n", { | |
| 510 | + | headers: { "content-type": "text/plain", "cache-control": fresh(86400) }, | |
| 511 | + | }); | |
| 512 | + | } | |
| 513 | + | const incident = /^\/incidents\/([a-z0-9-]{1,64})$/.exec(path); | |
| 514 | + | if (incident) { | |
| 515 | + | return cached(request, ctx, async () => { | |
| 516 | + | const found = await loadPublicIncident(env.DB, incident[1]!, origin); | |
| 517 | + | return found ? html(renderIncident(found.incident, found.postmortem, names(env), options), fresh()) : notFound(); | |
| 518 | + | }); | |
| 519 | + | } | |
| 520 | + | const maintenance = /^\/maintenance\/([a-z0-9-]{1,64})$/.exec(path); | |
| 521 | + | if (maintenance) { | |
| 522 | + | return cached(request, ctx, async () => { | |
| 523 | + | const found = await loadPublicMaintenance(env.DB, maintenance[1]!, origin); | |
| 524 | + | return found ? html(renderMaintenance(found, names(env), options), fresh()) : notFound(); | |
| 525 | + | }); | |
| 526 | + | } | |
| 527 | + | const font = FONTS[path]; | |
| 528 | + | if (font) { | |
| 529 | + | return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable", ...COMMON } }); | |
| 530 | + | } | |
| 531 | + | return notFound(); | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | export default { | |
| 535 | + | async fetch(request, env, ctx) { | |
| 536 | + | try { | |
| 537 | + | return await handle(request, env, ctx); | |
| 538 | + | } catch (error) { | |
| 539 | + | console.error(JSON.stringify({ event: "status.failed", path: new URL(request.url).pathname, error: String(error) })); | |
| 540 | + | return new Response("The status page could not be drawn. Try again in a minute.", { | |
| 541 | + | status: 503, | |
| 542 | + | headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", ...COMMON }, | |
| 543 | + | }); | |
| 544 | + | } | |
| 545 | + | }, | |
| 546 | + | async scheduled(_controller, env, ctx) { | |
| 547 | + | const now = new Date(); | |
| 548 | + | ctx.waitUntil( | |
| 549 | + | checkAll(env, now) | |
| 550 | + | .then(async (observations) => { | |
| 551 | + | const failing = observations.filter((o) => o.state === "down" || o.state === "degraded"); | |
| 552 | + | if (failing.length) console.warn(JSON.stringify({ event: "status.trouble", parts: failing })); | |
| 553 | + | await afterChecks(env, ctx, observations, now); | |
| 554 | + | }) | |
| 555 | + | .catch((error) => console.error(JSON.stringify({ event: "status.cron_failed", error: String(error) }))), | |
| 556 | + | ); | |
| 557 | + | }, | |
| 558 | + | } satisfies ExportedHandler<Env>; | |
| 559 | + | ||
| 560 | + | // --- Staff --------------------------------------------------------------------------------- | |
| 561 | + | ||
| 562 | + | /** | |
| 563 | + | * Staff only: running incidents and maintenance. Reached only through a | |
| 564 | + | * service binding (sudo's `STATUS`); status.g1t.sh's own address cannot. | |
| 565 | + | */ | |
| 566 | + | export class StatusAdmin extends WorkerEntrypoint<Env> implements StatusAdminApi { | |
| 567 | + | private known() { | |
| 568 | + | return parts(this.env).map((p) => p.key); | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | private origin() { | |
| 572 | + | return originOf(this.env); | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | private async detail(id: string): Promise<AdminIncidentDetail | null> { | |
| 576 | + | return incidentDetail(this.env.DB, String(id), this.origin(), names(this.env)); | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | private async summary(id: string): Promise<AdminIncident> { | |
| 580 | + | const { timeline: _t, followups: _f, postmortem: _p, postmortem_draft: _d, url: _u, ...incident } = (await this.detail(id))!; | |
| 581 | + | return incident; | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | /** Emails a public update to subscribers when asked; the count to keep with it. */ | |
| 585 | + | private async announce(incident: { id: string; title: string; components: { key: string; impact: string }[] }, status: keyof typeof INCIDENT_STATUS, text: string, wanted: boolean) { | |
| 586 | + | if (!wanted) return null; | |
| 587 | + | const about = incident.components.filter((c) => c.impact !== "operational").map((c) => c.key); | |
| 588 | + | return notify(this.env, this.ctx, about, { | |
| 589 | + | heading: `${INCIDENT_STATUS[status]}: ${incident.title}`, | |
| 590 | + | text, | |
| 591 | + | url: `${this.origin()}/incidents/${incident.id}`, | |
| 592 | + | }); | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | async components(): Promise<{ key: string; name: string }[]> { | |
| 596 | + | return parts(this.env).map(({ key, name }) => ({ key, name })); | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | async board(): Promise<StatusBoard> { | |
| 600 | + | return { ...(await board(this.env.DB, new Date(), this.origin())), email: emailOn(this.env) }; | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | async incident(id: string): Promise<AdminIncidentDetail | null> { | |
| 604 | + | return this.detail(id); | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | async openCount(): Promise<number> { | |
| 608 | + | return openCount(this.env.DB); | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | async declare(input: DeclareIncident): Promise<Result<AdminIncident>> { | |
| 612 | + | const checked = checkDeclare(input, this.known()); | |
| 613 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 614 | + | const v = checked.value; | |
| 615 | + | const now = new Date(); | |
| 616 | + | const entries: (Entry & { notified?: number | null })[] = [ | |
| 617 | + | { kind: "declared", public: false, status: null, text: `Declared ${SEVERITY_LABEL[v.severity]}.` }, | |
| 618 | + | ]; | |
| 619 | + | if (v.commander) entries.push({ kind: "role", public: false, status: null, text: `Incident commander: ${v.commander}.` }); | |
| 620 | + | if (v.communications) entries.push({ kind: "role", public: false, status: null, text: `Communications: ${v.communications}.` }); | |
| 621 | + | const id = shortId(); | |
| 622 | + | const status = v.status ?? "investigating"; | |
| 623 | + | const notified = await this.announce({ id, title: v.title, components: v.components }, status, v.message, v.notify); | |
| 624 | + | entries.push({ kind: "update", public: true, status, text: v.message, notified }); | |
| 625 | + | await createIncident( | |
| 626 | + | this.env.DB, | |
| 627 | + | { | |
| 628 | + | title: v.title, | |
| 629 | + | severity: v.severity, | |
| 630 | + | status, | |
| 631 | + | visibility: "public", | |
| 632 | + | source: "declared", | |
| 633 | + | components: v.components, | |
| 634 | + | started_at: v.started_at ?? now.toISOString(), | |
| 635 | + | acknowledged_at: now.toISOString(), | |
| 636 | + | commander: v.commander ?? null, | |
| 637 | + | communications: v.communications ?? null, | |
| 638 | + | by: v.by, | |
| 639 | + | }, | |
| 640 | + | entries, | |
| 641 | + | now, | |
| 642 | + | { action: "incident_declared", detail: `${SEVERITY_LABEL[v.severity]}: ${v.title}` }, | |
| 643 | + | id, | |
| 644 | + | ); | |
| 645 | + | console.log(JSON.stringify({ event: "status.incident_declared", id, by: v.by })); | |
| 646 | + | return ok(await this.summary(id)); | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | async update(id: string, change: IncidentChange): Promise<Result<AdminIncident>> { | |
| 650 | + | const checked = checkChange(change, this.known()); | |
| 651 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 652 | + | const existing = await this.detail(id); | |
| 653 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 654 | + | const now = new Date(); | |
| 655 | + | const applied = applyChange(facts(existing), checked.value, now, names(this.env)); | |
| 656 | + | if (!applied.ok) return fail("invalid", applied.error); | |
| 657 | + | const { next, entries } = applied.value; | |
| 658 | + | const update = entries.find((e) => e.kind === "update"); | |
| 659 | + | const notified = update | |
| 660 | + | ? await this.announce({ id: existing.id, title: existing.title, components: next.components }, next.status, update.text, checked.value.notify === true) | |
| 661 | + | : null; | |
| 662 | + | const lines = entries.map((e) => (e === update ? { ...e, notified } : e)); | |
| 663 | + | const action = next.status === "resolved" && existing.status !== "resolved" ? "incident_resolved" : update ? "incident_update" : "incident_note"; | |
| 664 | + | await saveIncident(this.env.DB, existing.id, next, lines, now, checked.value.by, { | |
| 665 | + | action, | |
| 666 | + | detail: entries.map((e) => (e.kind === "update" || e.kind === "note" ? `${e.kind === "update" ? "Public" : "Note"}: ${e.text}` : e.text)).join(" ").slice(0, 500), | |
| 667 | + | }); | |
| 668 | + | return ok(await this.summary(existing.id)); | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | async roles(id: string, change: RolesChange): Promise<Result<AdminIncident>> { | |
| 672 | + | const checked = checkRoles(change); | |
| 673 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 674 | + | const existing = await this.detail(id); | |
| 675 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 676 | + | const now = new Date(); | |
| 677 | + | const { next, entries } = applyRoles(facts(existing), checked.value, now); | |
| 678 | + | if (!entries.length) return ok(await this.summary(existing.id)); | |
| 679 | + | await saveIncident(this.env.DB, existing.id, next, entries, now, checked.value.by, { action: "incident_roles", detail: entries.map((e) => e.text).join(" ") }); | |
| 680 | + | return ok(await this.summary(existing.id)); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | async publish(id: string, input: PublishIncident): Promise<Result<AdminIncident>> { | |
| 684 | + | const checked = checkPublish(input); | |
| 685 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 686 | + | const existing = await this.detail(id); | |
| 687 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 688 | + | if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be published."); | |
| 689 | + | const now = new Date(); | |
| 690 | + | const at = now.toISOString(); | |
| 691 | + | const title = checked.value.title ?? existing.title; | |
| 692 | + | const next = { ...facts(existing), visibility: "public" as const, acknowledged_at: existing.acknowledged_at ?? at, title, published_at: at }; | |
| 693 | + | const notified = await this.announce({ id: existing.id, title, components: existing.components }, existing.status, checked.value.message, checked.value.notify); | |
| 694 | + | await saveIncident( | |
| 695 | + | this.env.DB, | |
| 696 | + | existing.id, | |
| 697 | + | next, | |
| 698 | + | [ | |
| 699 | + | ...(existing.acknowledged_at ? [] : [{ kind: "acknowledged" as const, public: false, status: null, text: "Acknowledged." }]), | |
| 700 | + | { kind: "published", public: false, status: null, text: title !== existing.title ? `Published as “${title}”.` : "Published to the status page." }, | |
| 701 | + | { kind: "update", public: true, status: existing.status, text: checked.value.message, notified }, | |
| 702 | + | ], | |
| 703 | + | now, | |
| 704 | + | checked.value.by, | |
| 705 | + | { action: "incident_published", detail: title }, | |
| 706 | + | ); | |
| 707 | + | return ok(await this.summary(existing.id)); | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | async dismiss(id: string, input: { reason: string; by: string }): Promise<Result<AdminIncident>> { | |
| 711 | + | const existing = await this.detail(id); | |
| 712 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 713 | + | if (existing.visibility !== "draft") return fail("conflict", "Only a draft can be dismissed; resolve a published incident instead."); | |
| 714 | + | const by = String(input?.by ?? "").trim(); | |
| 715 | + | if (!by) return fail("invalid", "Who is making the change is missing."); | |
| 716 | + | const reason = String(input?.reason ?? "").trim().slice(0, 500) || "No reason given."; | |
| 717 | + | const now = new Date(); | |
| 718 | + | const at = now.toISOString(); | |
| 719 | + | const next = { ...facts(existing), visibility: "dismissed" as const, status: "resolved" as const, acknowledged_at: existing.acknowledged_at ?? at, resolved_at: at }; | |
| 720 | + | await saveIncident(this.env.DB, existing.id, next, [{ kind: "dismissed", public: false, status: null, text: `Dismissed: ${reason}` }], now, by, { | |
| 721 | + | action: "incident_dismissed", | |
| 722 | + | detail: `${existing.title}: ${reason}`, | |
| 723 | + | }); | |
| 724 | + | return ok(await this.summary(existing.id)); | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | async addFollowUp(id: string, input: { title: string; owner: string | null; by: string }): Promise<Result<FollowUp>> { | |
| 728 | + | const checked = checkFollowUp(input); | |
| 729 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 730 | + | if (!(await this.detail(id))) return fail("not_found", "No such incident."); | |
| 731 | + | return ok(await addFollowUp(this.env.DB, String(id), checked.value, new Date())); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | async setFollowUp(id: string, followUp: string, input: { done: boolean; by: string }): Promise<Result<FollowUp>> { | |
| 735 | + | const by = String(input?.by ?? "").trim(); | |
| 736 | + | if (!by) return fail("invalid", "Who is making the change is missing."); | |
| 737 | + | const done = await setFollowUp(this.env.DB, String(id), String(followUp), input.done === true, by, new Date()); | |
| 738 | + | return done ? ok(done) : fail("not_found", "No such follow-up."); | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | async savePostmortem(id: string, input: PostmortemFields & { by: string }): Promise<Result<Postmortem>> { | |
| 742 | + | const checked = checkPostmortem(input); | |
| 743 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 744 | + | const existing = await this.detail(id); | |
| 745 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 746 | + | if (existing.visibility !== "public") return fail("conflict", "Only a published incident has a postmortem."); | |
| 747 | + | const { by, ...fields } = checked.value; | |
| 748 | + | await savePostmortem(this.env.DB, existing.id, fields, by, new Date()); | |
| 749 | + | return ok((await this.detail(existing.id))!.postmortem!); | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | async publishPostmortem(id: string, input: { publish: boolean; by: string }): Promise<Result<Postmortem>> { | |
| 753 | + | const by = String(input?.by ?? "").trim(); | |
| 754 | + | if (!by) return fail("invalid", "Who is making the change is missing."); | |
| 755 | + | const existing = await this.detail(id); | |
| 756 | + | if (!existing) return fail("not_found", "No such incident."); | |
| 757 | + | if (!existing.postmortem) return fail("conflict", "Save the postmortem before publishing it."); | |
| 758 | + | if (input.publish) { | |
| 759 | + | if (!existing.resolved_at) return fail("conflict", "Resolve the incident before publishing its postmortem."); | |
| 760 | + | const missing = postmortemReady(existing.postmortem); | |
| 761 | + | if (missing) return fail("invalid", missing); | |
| 762 | + | } | |
| 763 | + | await publishPostmortem(this.env.DB, existing.id, input.publish === true, by, new Date()); | |
| 764 | + | return ok((await this.detail(existing.id))!.postmortem!); | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | async scheduleMaintenance(input: NewMaintenance): Promise<Result<AdminMaintenance>> { | |
| 768 | + | const checked = checkMaintenance(input, this.known()); | |
| 769 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 770 | + | const v = checked.value; | |
| 771 | + | const now = new Date(); | |
| 772 | + | const id = shortId(); | |
| 773 | + | const notified = v.notify | |
| 774 | + | ? await notify(this.env, this.ctx, v.components, { | |
| 775 | + | heading: `Planned maintenance: ${v.title}`, | |
| 776 | + | text: `${v.message}\n\nWhen: ${stamp(v.starts_at)} to ${stamp(v.ends_at)}.`, | |
| 777 | + | url: maintenanceUrl(this.origin(), id), | |
| 778 | + | }) | |
| 779 | + | : null; | |
| 780 | + | const made = await scheduleMaintenance(this.env.DB, v, notified, now, id); | |
| 781 | + | return ok((await maintenanceById(this.env.DB, made, this.origin()))!); | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | async changeMaintenance(id: string, change: MaintenanceChange): Promise<Result<AdminMaintenance>> { | |
| 785 | + | const checked = checkMaintenanceChange(change); | |
| 786 | + | if (!checked.ok) return fail("invalid", checked.error); | |
| 787 | + | const existing = await maintenanceById(this.env.DB, String(id), this.origin()); | |
| 788 | + | if (!existing) return fail("not_found", "No such maintenance."); | |
| 789 | + | const v = checked.value; | |
| 790 | + | if (existing.state === "completed" || existing.state === "cancelled") return fail("conflict", `This maintenance is ${existing.state}.`); | |
| 791 | + | if (v.action === "start" && existing.state !== "scheduled") return fail("conflict", "It has already started."); | |
| 792 | + | const state = v.action === "start" ? "in_progress" : v.action === "complete" ? "completed" : v.action === "cancel" ? "cancelled" : null; | |
| 793 | + | const text = | |
| 794 | + | v.message || | |
| 795 | + | (v.action === "start" ? "The maintenance has begun." : v.action === "complete" ? "The maintenance is complete." : "This maintenance is cancelled."); | |
| 796 | + | const word = { update: "Update", start: "In progress", complete: "Completed", cancel: "Cancelled" }[v.action]; | |
| 797 | + | const notified = v.notify ? await notify(this.env, this.ctx, existing.components, { heading: `${word}: ${existing.title}`, text, url: existing.url }) : null; | |
| 798 | + | await maintenanceUpdate(this.env.DB, existing.id, state, text, v.by, notified, new Date(), { | |
| 799 | + | action: `maintenance_${v.action === "update" ? "update" : v.action === "start" ? "started" : v.action === "complete" ? "completed" : "cancelled"}`, | |
| 800 | + | detail: `${existing.title}: ${text}`, | |
| 801 | + | }); | |
| 802 | + | return ok((await maintenanceById(this.env.DB, existing.id, this.origin()))!); | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | async audit(filter?: { before?: string | null }): Promise<StatusAuditEntry[]> { | |
| 806 | + | const before = filter?.before && !Number.isNaN(Date.parse(filter.before)) ? filter.before : null; | |
| 807 | + | return auditLog(this.env.DB, before); | |
| 808 | + | } | |
| 809 | + | } | |
| 810 | + |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { components } from "./components.ts"; | |
| 5 | + | import { | |
| 6 | + | type DayRow, | |
| 7 | + | barFor, | |
| 8 | + | buildPage, | |
| 9 | + | classify, | |
| 10 | + | dayState, | |
| 11 | + | effectiveState, | |
| 12 | + | lastDays, | |
| 13 | + | maintenanceState, | |
| 14 | + | openOn, | |
| 15 | + | percent, | |
| 16 | + | recentIncidents, | |
| 17 | + | summarize, | |
| 18 | + | uptimeOf, | |
| 19 | + | uptimeOver, | |
| 20 | + | } from "./model.ts"; | |
| 21 | + | ||
| 22 | + | const VARS = { | |
| 23 | + | SITE_URL: "https://g1t.sh", | |
| 24 | + | API_URL: "https://api.g1t.sh", | |
| 25 | + | MCP_URL: "https://mcp.g1t.sh", | |
| 26 | + | DOCS_URL: "https://docs.g1t.sh", | |
| 27 | + | PAGES_URL: "https://g1t.page", | |
| 28 | + | MODELS_URL: "https://models.g1t.sh", | |
| 29 | + | PROBE_REPO: "flagon-io/g1t", | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | const row = (component: string, day: string, up: number, degraded = 0, down = 0): DayRow => ({ | |
| 33 | + | component, | |
| 34 | + | day, | |
| 35 | + | checks: up + degraded + down, | |
| 36 | + | up, | |
| 37 | + | degraded, | |
| 38 | + | down, | |
| 39 | + | latency_total: (up + degraded) * 100, | |
| 40 | + | latency_count: up + degraded, | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("classify: fast is up, slow is degraded, failed is down, none is unmonitored", () => { | |
| 44 | + | assert.equal(classify({ ok: true, ms: 84.4 }).state, "up"); | |
| 45 | + | assert.equal(classify({ ok: true, ms: 84.4 }).detail, "Answered in 84 ms"); | |
| 46 | + | assert.equal(classify({ ok: true, ms: 2000 }).state, "degraded"); | |
| 47 | + | assert.deepEqual(classify({ ok: false, ms: 5000, error: "timed out" }), { state: "down", detail: "Failed: timed out" }); | |
| 48 | + | assert.equal(classify(null).state, "unmonitored"); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("uptime rounds down, so one failure never reads as 100%", () => { | |
| 52 | + | assert.equal(uptimeOf(1439, 1440), 99.93); | |
| 53 | + | assert.equal(uptimeOf(99999, 100000), 99.99); | |
| 54 | + | assert.equal(uptimeOf(10, 10), 100); | |
| 55 | + | assert.equal(uptimeOf(0, 0), null); | |
| 56 | + | assert.equal(percent(100), "100%"); | |
| 57 | + | assert.equal(percent(99.5), "99.50%"); | |
| 58 | + | assert.equal(percent(null), "No data"); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("a day's colour: a blip stays green, a bad hour is degraded, worse is down", () => { | |
| 62 | + | assert.equal(dayState(null), "none"); | |
| 63 | + | assert.equal(dayState({ checks: 1440, up: 1439, degraded: 0, down: 1 }), "up"); | |
| 64 | + | assert.equal(dayState({ checks: 1440, up: 1380, degraded: 0, down: 60 }), "degraded"); | |
| 65 | + | assert.equal(dayState({ checks: 1440, up: 1000, degraded: 0, down: 440 }), "down"); | |
| 66 | + | // Answering, but slow much of the day. | |
| 67 | + | assert.equal(dayState({ checks: 100, up: 60, degraded: 40, down: 0 }), "degraded"); | |
| 68 | + | // An incident colours the day at least as badly as its impact. | |
| 69 | + | assert.equal(dayState({ checks: 1440, up: 1440, degraded: 0, down: 0 }, "down"), "down"); | |
| 70 | + | assert.equal(dayState(null, "degraded"), "degraded"); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("lastDays ends today, oldest first", () => { | |
| 74 | + | const days = lastDays(new Date("2026-10-05T12:00:00Z")); | |
| 75 | + | assert.equal(days.length, 90); | |
| 76 | + | assert.equal(days.at(-1), "2026-10-05"); | |
| 77 | + | assert.equal(days[0], "2026-07-08"); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | test("an incident is on every day it was open", () => { | |
| 81 | + | const incident = { started_at: "2026-10-03T23:30:00Z", resolved_at: "2026-10-04T00:10:00Z" }; | |
| 82 | + | assert.equal(openOn(incident, "2026-10-02"), false); | |
| 83 | + | assert.equal(openOn(incident, "2026-10-03"), true); | |
| 84 | + | assert.equal(openOn(incident, "2026-10-04"), true); | |
| 85 | + | assert.equal(openOn(incident, "2026-10-05"), false); | |
| 86 | + | assert.equal(openOn({ started_at: "2026-10-01T00:00:00Z", resolved_at: null }, "2026-10-05"), true); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | test("bars and 90-day uptime come from the daily tally", () => { | |
| 90 | + | const days = ["2026-10-03", "2026-10-04", "2026-10-05"]; | |
| 91 | + | const rows = [row("api", "2026-10-04", 1439, 0, 1), row("api", "2026-10-05", 600), row("git", "2026-10-05", 10)]; | |
| 92 | + | const bar = barFor("api", rows, [], days); | |
| 93 | + | assert.deepEqual( | |
| 94 | + | bar.map((d) => d.state), | |
| 95 | + | ["none", "up", "up"], | |
| 96 | + | ); | |
| 97 | + | assert.equal(bar[1]!.failed, 1); | |
| 98 | + | assert.equal(bar[1]!.avg_ms, 100); | |
| 99 | + | assert.equal(uptimeOver("api", rows), uptimeOf(2039, 2040)); | |
| 100 | + | assert.equal(uptimeOver("docs", rows), null); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | test("a bar marks the days an incident on its part was open", () => { | |
| 104 | + | const incident = { | |
| 105 | + | id: "1", | |
| 106 | + | title: "Clones failing", | |
| 107 | + | impact: "down" as const, | |
| 108 | + | status: "resolved" as const, | |
| 109 | + | components: ["git"], | |
| 110 | + | component_impacts: [{ key: "git", impact: "major_outage" as const }], | |
| 111 | + | started_at: "2026-10-04T10:00:00Z", | |
| 112 | + | resolved_at: "2026-10-04T11:00:00Z", | |
| 113 | + | url: "https://status.g1t.sh/incidents/1", | |
| 114 | + | postmortem_published_at: null, | |
| 115 | + | updates: [], | |
| 116 | + | }; | |
| 117 | + | const days = ["2026-10-04", "2026-10-05"]; | |
| 118 | + | const git = barFor("git", [row("git", "2026-10-04", 1440)], [incident], days); | |
| 119 | + | assert.equal(git[0]!.state, "down"); | |
| 120 | + | assert.deepEqual(git[0]!.incidents, ["Clones failing"]); | |
| 121 | + | assert.equal(barFor("api", [row("api", "2026-10-04", 1440)], [incident], days)[0]!.state, "up"); | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | test("summarize: core down is a major outage, others partial, slow is degraded performance", () => { | |
| 125 | + | const core = new Set(["site", "api"]); | |
| 126 | + | const up = { key: "api", name: "API", state: "up" as const }; | |
| 127 | + | assert.equal(summarize([up], core).title, "All systems normal"); | |
| 128 | + | assert.equal(summarize([{ ...up, state: "down" }], core).title, "Major outage"); | |
| 129 | + | assert.equal(summarize([up, { key: "docs", name: "Documentation", state: "down" }], core).title, "Partial outage"); | |
| 130 | + | const slow = summarize([up, { key: "docs", name: "Documentation", state: "degraded" }], core); | |
| 131 | + | assert.equal(slow.title, "Degraded performance"); | |
| 132 | + | assert.match(slow.line, /Documentation/); | |
| 133 | + | assert.equal(summarize([{ key: "sandboxes", name: "Sandboxes", state: "unmonitored" }], core).state, "unknown"); | |
| 134 | + | const incident = summarize([up], core, [{ title: "Pushes failing", impact: "degraded", status: "identified" }]); | |
| 135 | + | assert.equal(incident.title, "Partial outage"); | |
| 136 | + | assert.equal(incident.line, "Identified: Pushes failing."); | |
| 137 | + | }); | |
| 138 | + | ||
| 139 | + | test("recent incidents: open ones and those resolved in 90 days, newest first", () => { | |
| 140 | + | const now = new Date("2026-10-05T00:00:00Z"); | |
| 141 | + | const list = recentIncidents( | |
| 142 | + | [ | |
| 143 | + | { id: "old", started_at: "2026-01-01T00:00:00Z", resolved_at: "2026-01-01T01:00:00Z" }, | |
| 144 | + | { id: "open", started_at: "2026-01-02T00:00:00Z", resolved_at: null }, | |
| 145 | + | { id: "new", started_at: "2026-10-01T00:00:00Z", resolved_at: "2026-10-01T01:00:00Z" }, | |
| 146 | + | ], | |
| 147 | + | now, | |
| 148 | + | ); | |
| 149 | + | assert.deepEqual( | |
| 150 | + | list.map((i) => i.id), | |
| 151 | + | ["new", "open"], | |
| 152 | + | ); | |
| 153 | + | }); | |
| 154 | + | ||
| 155 | + | test("buildPage: parts with no check are unmonitored and have no uptime; staleness is noticed", () => { | |
| 156 | + | const now = new Date("2026-10-05T12:00:00Z"); | |
| 157 | + | const parts = components(VARS, true); | |
| 158 | + | const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }])); | |
| 159 | + | const page = buildPage({ | |
| 160 | + | parts, | |
| 161 | + | current, | |
| 162 | + | checkedAt: "2026-10-05T11:59:30Z", | |
| 163 | + | days: [row("api", "2026-10-05", 700)], | |
| 164 | + | incidents: [], | |
| 165 | + | now, | |
| 166 | + | }); | |
| 167 | + | const sandboxes = page.report.components.find((c) => c.key === "sandboxes")!; | |
| 168 | + | assert.equal(sandboxes.state, "unmonitored"); | |
| 169 | + | assert.equal(sandboxes.uptime_90d, null); | |
| 170 | + | assert.equal(page.report.components.find((c) => c.key === "api")!.uptime_90d, 100); | |
| 171 | + | assert.equal(page.report.overall.title, "All systems normal"); | |
| 172 | + | assert.equal(page.stale, false); | |
| 173 | + | assert.equal(page.bars.api!.length, 90); | |
| 174 | + | assert.equal(buildPage({ parts, current, checkedAt: "2026-10-05T11:00:00Z", days: [], incidents: [], now }).stale, true); | |
| 175 | + | }); | |
| 176 | + | ||
| 177 | + | test("components: an installation without a host leaves that part off", () => { | |
| 178 | + | const keys = components({ SITE_URL: "http://localhost:8787", API_URL: "http://localhost:8787/api", PROBE_REPO: "me/demo" }, false).map( | |
| 179 | + | (c) => c.key, | |
| 180 | + | ); | |
| 181 | + | assert.deepEqual(keys, ["site", "api", "git"]); | |
| 182 | + | const bare = components({ SITE_URL: "http://g1t:8787" }, false); | |
| 183 | + | assert.deepEqual( | |
| 184 | + | bare.map((c) => c.key), | |
| 185 | + | ["site"], | |
| 186 | + | ); | |
| 187 | + | assert.equal(bare[0]!.check.kind === "http" && bare[0]!.check.steps.length, 1); | |
| 188 | + | const all = components(VARS, true); | |
| 189 | + | assert.deepEqual( | |
| 190 | + | all.map((c) => c.key), | |
| 191 | + | ["site", "api", "git", "mcp", "docs", "deployments", "agents", "sandboxes", "billing"], | |
| 192 | + | ); | |
| 193 | + | const git = all.find((c) => c.key === "git")!; | |
| 194 | + | assert.deepEqual(git.check, { kind: "http", steps: [{ url: "https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-upload-pack" }] }); | |
| 195 | + | }); | |
| 196 | + | ||
| 197 | + | test("a part's state: the worse of its check and open incidents; maintenance unless an incident says worse", () => { | |
| 198 | + | assert.equal(effectiveState("up", [], false), "up"); | |
| 199 | + | assert.equal(effectiveState("up", ["partial_outage"], false), "partial"); | |
| 200 | + | assert.equal(effectiveState("down", ["degraded"], false), "down", "a failing check beats a milder report"); | |
| 201 | + | assert.equal(effectiveState("degraded", ["major_outage", "degraded"], false), "down", "the worst impact wins"); | |
| 202 | + | assert.equal(effectiveState("up", ["operational"], false), "up"); | |
| 203 | + | assert.equal(effectiveState("unmonitored", ["degraded"], false), "degraded", "an incident speaks for a part with no check"); | |
| 204 | + | assert.equal(effectiveState("down", [], true), "maintenance", "failing checks in a window are expected"); | |
| 205 | + | assert.equal(effectiveState("up", ["major_outage"], true), "down", "an incident during maintenance still shows"); | |
| 206 | + | }); | |
| 207 | + | ||
| 208 | + | test("maintenance moves with its window, and cancelled or completed stays put", () => { | |
| 209 | + | const m = { state: "scheduled" as const, starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z" }; | |
| 210 | + | assert.equal(maintenanceState(m, new Date("2026-10-05T11:59:00Z")), "scheduled"); | |
| 211 | + | assert.equal(maintenanceState(m, new Date("2026-10-05T12:00:00Z")), "in_progress"); | |
| 212 | + | assert.equal(maintenanceState(m, new Date("2026-10-05T13:00:00Z")), "completed"); | |
| 213 | + | assert.equal(maintenanceState({ ...m, state: "in_progress" }, new Date("2026-10-05T11:00:00Z")), "in_progress", "started early by hand"); | |
| 214 | + | assert.equal(maintenanceState({ ...m, state: "cancelled" }, new Date("2026-10-05T12:30:00Z")), "cancelled"); | |
| 215 | + | assert.equal(maintenanceState({ ...m, state: "completed" }, new Date("2026-10-05T12:30:00Z")), "completed", "finished early by hand"); | |
| 216 | + | }); | |
| 217 | + | ||
| 218 | + | test("buildPage: incident impacts and maintenance reach the parts and the banner", () => { | |
| 219 | + | const now = new Date("2026-10-05T12:30:00Z"); | |
| 220 | + | const parts = components(VARS, false); | |
| 221 | + | const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }])); | |
| 222 | + | current.set("docs", { state: "down", detail: "Failed: HTTP 502", latency_ms: null }); | |
| 223 | + | const maintenance = [ | |
| 224 | + | { id: "m1", title: "Docs move", message: "Moving docs.", components: ["docs"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "scheduled" as const, url: "", updates: [] }, | |
| 225 | + | { id: "m2", title: "Later", message: "Later.", components: ["api"], starts_at: "2026-10-06T12:00:00Z", ends_at: "2026-10-06T13:00:00Z", state: "scheduled" as const, url: "", updates: [] }, | |
| 226 | + | { id: "m3", title: "Called off", message: "No.", components: ["api"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "cancelled" as const, url: "", updates: [] }, | |
| 227 | + | ]; | |
| 228 | + | const quiet = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [], maintenance, now }); | |
| 229 | + | const docs = quiet.report.components.find((c) => c.key === "docs")!; | |
| 230 | + | assert.equal(docs.state, "maintenance"); | |
| 231 | + | assert.equal(docs.check_state, "down"); | |
| 232 | + | assert.equal(quiet.report.overall.state, "maintenance"); | |
| 233 | + | assert.equal(quiet.report.overall.title, "Under maintenance"); | |
| 234 | + | assert.deepEqual(quiet.report.maintenance.map((m) => [m.id, m.state]), [["m1", "in_progress"], ["m2", "scheduled"]]); | |
| 235 | + | ||
| 236 | + | const incident = { | |
| 237 | + | id: "i1", | |
| 238 | + | title: "API errors", | |
| 239 | + | impact: "degraded" as const, | |
| 240 | + | status: "identified" as const, | |
| 241 | + | components: ["api"], | |
| 242 | + | component_impacts: [{ key: "api", impact: "partial_outage" as const }], | |
| 243 | + | started_at: "2026-10-05T12:10:00Z", | |
| 244 | + | resolved_at: null, | |
| 245 | + | url: "", | |
| 246 | + | postmortem_published_at: null, | |
| 247 | + | updates: [], | |
| 248 | + | }; | |
| 249 | + | const busy = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [incident], maintenance, now }); | |
| 250 | + | assert.equal(busy.report.components.find((c) => c.key === "api")!.state, "partial"); | |
| 251 | + | assert.equal(busy.report.overall.title, "Partial outage"); | |
| 252 | + | assert.equal(busy.report.overall.line, "Identified: API errors."); | |
| 253 | + | const resolved = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [{ ...incident, resolved_at: "2026-10-05T12:20:00Z" }], now }); | |
| 254 | + | assert.equal(resolved.report.components.find((c) => c.key === "api")!.state, "up", "a resolved incident no longer colours its part"); | |
| 255 | + | }); |
| 1 | + | /** | |
| 2 | + | * What a check's result means, how the parts add up to one line for the | |
| 3 | + | * whole, and the 90 days of history behind each uptime bar. No Workers | |
| 4 | + | * imports, so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { | |
| 7 | + | ComponentImpact, | |
| 8 | + | IncidentImpact, | |
| 9 | + | IncidentStatus, | |
| 10 | + | MaintenanceState, | |
| 11 | + | StatusComponent, | |
| 12 | + | StatusComponentState, | |
| 13 | + | StatusIncident, | |
| 14 | + | StatusMaintenance, | |
| 15 | + | StatusOverall, | |
| 16 | + | StatusOverallState, | |
| 17 | + | StatusReport, | |
| 18 | + | } from "@g1t/contracts"; | |
| 19 | + | ||
| 20 | + | import type { ComponentInfo } from "./components.ts"; | |
| 21 | + | import type { ProbeResult } from "./probe.ts"; | |
| 22 | + | ||
| 23 | + | /** Slower than this, a part that answered counts as degraded. */ | |
| 24 | + | export const SLOW_MS = 1500; | |
| 25 | + | /** How many days of history the page shows and keeps. */ | |
| 26 | + | export const HISTORY_DAYS = 90; | |
| 27 | + | /** Checks older than this mean the checker has stopped: the page says so. */ | |
| 28 | + | export const STALE_MS = 10 * 60 * 1000; | |
| 29 | + | ||
| 30 | + | /** What a check's result means for its part. `null` is a part with no check. */ | |
| 31 | + | export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: StatusComponentState; detail: string } { | |
| 32 | + | if (!result) return { state: "unmonitored", detail: "Not monitored yet" }; | |
| 33 | + | if (!result.ok) return { state: "down", detail: `Failed: ${result.error ?? "no answer"}` }; | |
| 34 | + | const ms = Math.round(result.ms); | |
| 35 | + | if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` }; | |
| 36 | + | return { state: "up", detail: `Answered in ${ms} ms` }; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** A part's state at its last check, as stored. */ | |
| 40 | + | export type Current = { state: StatusComponentState; detail: string; latency_ms: number | null }; | |
| 41 | + | ||
| 42 | + | /** One part's checks on one UTC day, as stored. */ | |
| 43 | + | export type DayRow = { | |
| 44 | + | component: string; | |
| 45 | + | /** `YYYY-MM-DD`, UTC. */ | |
| 46 | + | day: string; | |
| 47 | + | checks: number; | |
| 48 | + | up: number; | |
| 49 | + | degraded: number; | |
| 50 | + | down: number; | |
| 51 | + | latency_total: number; | |
| 52 | + | latency_count: number; | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | /** One day's square on a bar. */ | |
| 56 | + | export type DayBar = { | |
| 57 | + | day: string; | |
| 58 | + | state: "up" | "degraded" | "down" | "none"; | |
| 59 | + | /** Share of checks that answered, 0 to 100; null with none. */ | |
| 60 | + | uptime: number | null; | |
| 61 | + | checks: number; | |
| 62 | + | failed: number; | |
| 63 | + | slow: number; | |
| 64 | + | avg_ms: number | null; | |
| 65 | + | /** Incidents on this part that were open some time that day. */ | |
| 66 | + | incidents: string[]; | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 70 | + | ||
| 71 | + | /** `YYYY-MM-DD` of a time, in UTC. */ | |
| 72 | + | export function dayOf(at: Date | number): string { | |
| 73 | + | return new Date(at).toISOString().slice(0, 10); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** The last `count` days, oldest first, ending today. */ | |
| 77 | + | export function lastDays(now: Date, count = HISTORY_DAYS): string[] { | |
| 78 | + | const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); | |
| 79 | + | return Array.from({ length: count }, (_, i) => dayOf(today - (count - 1 - i) * DAY_MS)); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Share of checks answered, rounded down to hundredths so a single failure | |
| 84 | + | * never shows as 100%. | |
| 85 | + | */ | |
| 86 | + | export function uptimeOf(answered: number, checks: number): number | null { | |
| 87 | + | if (checks <= 0) return null; | |
| 88 | + | return Math.floor((answered / checks) * 10000) / 100; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** Words for an uptime: "100%", "99.93%". */ | |
| 92 | + | export function percent(uptime: number | null): string { | |
| 93 | + | if (uptime == null) return "No data"; | |
| 94 | + | return uptime >= 100 ? "100%" : `${uptime.toFixed(2)}%`; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | const LEVEL = { none: 0, up: 1, degraded: 2, down: 3 } as const; | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * A day's colour: answered at least 99.9% of the time is up (one blip in a | |
| 101 | + | * day of minutely checks), unless over a quarter of answers were slow; at | |
| 102 | + | * least 95% is degraded; less is down. An incident on the part that day | |
| 103 | + | * colours it at least as badly as its impact. | |
| 104 | + | */ | |
| 105 | + | export function dayState(row: Pick<DayRow, "checks" | "up" | "degraded" | "down"> | null, incidentImpact: IncidentImpact | null = null): DayBar["state"] { | |
| 106 | + | let state: DayBar["state"] = "none"; | |
| 107 | + | if (row && row.checks > 0) { | |
| 108 | + | const uptime = ((row.up + row.degraded) / row.checks) * 100; | |
| 109 | + | if (uptime >= 99.9) state = row.degraded / row.checks > 0.25 ? "degraded" : "up"; | |
| 110 | + | else if (uptime >= 95) state = "degraded"; | |
| 111 | + | else state = "down"; | |
| 112 | + | } | |
| 113 | + | if (incidentImpact && LEVEL[incidentImpact] > LEVEL[state]) state = incidentImpact; | |
| 114 | + | return state; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /** Whether an incident was open at some time during `day`. */ | |
| 118 | + | export function openOn(incident: Pick<StatusIncident, "started_at" | "resolved_at">, day: string): boolean { | |
| 119 | + | const start = Date.parse(`${day}T00:00:00Z`); | |
| 120 | + | const end = start + DAY_MS; | |
| 121 | + | const began = Date.parse(incident.started_at); | |
| 122 | + | const ended = incident.resolved_at ? Date.parse(incident.resolved_at) : Number.POSITIVE_INFINITY; | |
| 123 | + | return began < end && ended >= start; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /** A part's bar: one square per day, oldest first. */ | |
| 127 | + | export function barFor( | |
| 128 | + | key: string, | |
| 129 | + | rows: DayRow[], | |
| 130 | + | incidents: Pick<StatusIncident, "title" | "started_at" | "resolved_at" | "component_impacts">[], | |
| 131 | + | days: string[], | |
| 132 | + | ): DayBar[] { | |
| 133 | + | const byDay = new Map(rows.filter((r) => r.component === key).map((r) => [r.day, r])); | |
| 134 | + | const mine = incidents | |
| 135 | + | .map((i) => ({ incident: i, impact: i.component_impacts.find((c) => c.key === key)?.impact ?? null })) | |
| 136 | + | .filter((m) => m.impact != null && m.impact !== "operational"); | |
| 137 | + | return days.map((day) => { | |
| 138 | + | const row = byDay.get(day) ?? null; | |
| 139 | + | const during = mine.filter((m) => openOn(m.incident, day)); | |
| 140 | + | const impact = during.some((m) => m.impact === "major_outage") ? "down" : during.length ? "degraded" : null; | |
| 141 | + | return { | |
| 142 | + | day, | |
| 143 | + | state: dayState(row, impact), | |
| 144 | + | uptime: row ? uptimeOf(row.up + row.degraded, row.checks) : null, | |
| 145 | + | checks: row?.checks ?? 0, | |
| 146 | + | failed: row?.down ?? 0, | |
| 147 | + | slow: row?.degraded ?? 0, | |
| 148 | + | avg_ms: row && row.latency_count > 0 ? Math.round(row.latency_total / row.latency_count) : null, | |
| 149 | + | incidents: during.map((m) => m.incident.title), | |
| 150 | + | }; | |
| 151 | + | }); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /** A part's uptime over the days given. */ | |
| 155 | + | export function uptimeOver(key: string, rows: DayRow[]): number | null { | |
| 156 | + | let checks = 0; | |
| 157 | + | let answered = 0; | |
| 158 | + | for (const row of rows) { | |
| 159 | + | if (row.component !== key) continue; | |
| 160 | + | checks += row.checks; | |
| 161 | + | answered += row.up + row.degraded; | |
| 162 | + | } | |
| 163 | + | return uptimeOf(answered, checks); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | ||
| 167 | + | const RANK: Record<StatusOverallState, number> = { unknown: 0, up: 1, maintenance: 1.5, degraded: 2, down: 3 }; | |
| 168 | + | ||
| 169 | + | function worse(a: StatusOverallState, b: StatusOverallState): StatusOverallState { | |
| 170 | + | return RANK[b] > RANK[a] ? b : a; | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /** Words for an incident's status. */ | |
| 174 | + | export const INCIDENT_STATUS: Record<IncidentStatus, string> = { | |
| 175 | + | investigating: "Investigating", | |
| 176 | + | identified: "Identified", | |
| 177 | + | monitoring: "Monitoring", | |
| 178 | + | resolved: "Resolved", | |
| 179 | + | }; | |
| 180 | + | ||
| 181 | + | /** Words for what an incident does to a part. */ | |
| 182 | + | export const IMPACT_WORD: Record<ComponentImpact, string> = { | |
| 183 | + | operational: "Operational", | |
| 184 | + | degraded: "Degraded performance", | |
| 185 | + | partial_outage: "Partial outage", | |
| 186 | + | major_outage: "Major outage", | |
| 187 | + | }; | |
| 188 | + | ||
| 189 | + | /** A part's state for an impact on it. */ | |
| 190 | + | export const IMPACT_STATE: Record<ComponentImpact, StatusComponentState> = { | |
| 191 | + | operational: "up", | |
| 192 | + | degraded: "degraded", | |
| 193 | + | partial_outage: "partial", | |
| 194 | + | major_outage: "down", | |
| 195 | + | }; | |
| 196 | + | ||
| 197 | + | /** How bad each state is: the worse of a check and an incident wins. */ | |
| 198 | + | const STATE_RANK: Record<StatusComponentState, number> = { unmonitored: 0, up: 1, maintenance: 1, degraded: 2, partial: 3, down: 4 }; | |
| 199 | + | ||
| 200 | + | /** An incident's impact as a whole: `down` when any part has a major outage. */ | |
| 201 | + | export function overallImpact(impacts: { impact: ComponentImpact }[]): IncidentImpact { | |
| 202 | + | return impacts.some((i) => i.impact === "major_outage") ? "down" : "degraded"; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /** | |
| 206 | + | * What the page shows for a part: the worse of its last check and what | |
| 207 | + | * open incidents say about it. During maintenance it is "Under | |
| 208 | + | * maintenance", unless an incident says worse, since checks failing in a | |
| 209 | + | * window are expected. | |
| 210 | + | */ | |
| 211 | + | export function effectiveState(check: StatusComponentState, impacts: ComponentImpact[], underMaintenance: boolean): StatusComponentState { | |
| 212 | + | let reported: StatusComponentState | null = null; | |
| 213 | + | for (const impact of impacts) { | |
| 214 | + | const state = IMPACT_STATE[impact]; | |
| 215 | + | if (reported == null || STATE_RANK[state] > STATE_RANK[reported]) reported = state; | |
| 216 | + | } | |
| 217 | + | if (underMaintenance && (reported == null || reported === "up")) return "maintenance"; | |
| 218 | + | if (reported == null) return check; | |
| 219 | + | if (check === "unmonitored" || check === "maintenance") return reported; | |
| 220 | + | return STATE_RANK[reported] > STATE_RANK[check] ? reported : check; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /** | |
| 224 | + | * Where maintenance stands at `now`. Cancelled and completed are final; | |
| 225 | + | * otherwise the window decides, so the page is right even if the | |
| 226 | + | * minutely job that moves it along is late. | |
| 227 | + | */ | |
| 228 | + | export function maintenanceState(m: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at">, now: Date): MaintenanceState { | |
| 229 | + | if (m.state === "cancelled" || m.state === "completed") return m.state; | |
| 230 | + | const t = now.getTime(); | |
| 231 | + | if (t >= Date.parse(m.ends_at)) return "completed"; | |
| 232 | + | if (t >= Date.parse(m.starts_at) || m.state === "in_progress") return "in_progress"; | |
| 233 | + | return "scheduled"; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /** The parts under maintenance at `now`. */ | |
| 237 | + | export function underMaintenance( | |
| 238 | + | list: Pick<StatusMaintenance, "state" | "starts_at" | "ends_at" | "components">[], | |
| 239 | + | now: Date, | |
| 240 | + | ): Set<string> { | |
| 241 | + | const keys = new Set<string>(); | |
| 242 | + | for (const m of list) if (maintenanceState(m, now) === "in_progress") for (const k of m.components) keys.add(k); | |
| 243 | + | return keys; | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /** | |
| 247 | + | * The whole, from its parts and its open incidents: a major outage when a | |
| 248 | + | * core part is down or an incident says so; a partial outage when any part | |
| 249 | + | * is down or partly down, or an incident is open; degraded performance | |
| 250 | + | * when parts are only slow; under maintenance when work is under way and | |
| 251 | + | * nothing else is wrong. Parts with no check never make it look better or | |
| 252 | + | * worse. | |
| 253 | + | */ | |
| 254 | + | export function summarize( | |
| 255 | + | parts: Pick<StatusComponent, "key" | "name" | "state">[], | |
| 256 | + | core: Set<string>, | |
| 257 | + | open: Pick<StatusIncident, "title" | "impact" | "status">[] = [], | |
| 258 | + | maintenance: Pick<StatusMaintenance, "title">[] = [], | |
| 259 | + | ): StatusOverall { | |
| 260 | + | const monitored = parts.filter((c) => c.state !== "unmonitored"); | |
| 261 | + | if (monitored.length === 0 && open.length === 0) { | |
| 262 | + | return { state: "unknown", title: "Status unavailable", line: "No checks have run yet." }; | |
| 263 | + | } | |
| 264 | + | let state: StatusOverallState = monitored.length > 0 ? "up" : "unknown"; | |
| 265 | + | for (const c of monitored) { | |
| 266 | + | if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded"); | |
| 267 | + | else if (c.state === "partial" || c.state === "degraded") state = worse(state, "degraded"); | |
| 268 | + | else if (c.state === "maintenance") state = worse(state, "maintenance"); | |
| 269 | + | } | |
| 270 | + | for (const incident of open) state = worse(state, incident.impact); | |
| 271 | + | ||
| 272 | + | const down = monitored.filter((c) => c.state === "down" || c.state === "partial").map((c) => c.name); | |
| 273 | + | const slow = monitored.filter((c) => c.state === "degraded").map((c) => c.name); | |
| 274 | + | const lead = open[0]; | |
| 275 | + | const incidentLine = lead ? `${INCIDENT_STATUS[lead.status]}: ${lead.title}.` : null; | |
| 276 | + | const named = (names: string[]) => names.join(", "); | |
| 277 | + | ||
| 278 | + | if (state === "up") return { state, title: "All systems normal", line: "Every part of g1t answered its last check." }; | |
| 279 | + | if (state === "maintenance") { | |
| 280 | + | const work = maintenance[0]; | |
| 281 | + | return { state, title: "Under maintenance", line: work ? `Planned work: ${work.title}.` : "Planned work is under way." }; | |
| 282 | + | } | |
| 283 | + | if (state === "down") { | |
| 284 | + | return { | |
| 285 | + | state, | |
| 286 | + | title: "Major outage", | |
| 287 | + | line: incidentLine ?? (down.length ? `Not answering: ${named(down)}.` : "g1t is not working right now."), | |
| 288 | + | }; | |
| 289 | + | } | |
| 290 | + | if (state === "degraded") { | |
| 291 | + | if (down.length || open.length) { | |
| 292 | + | return { | |
| 293 | + | state, | |
| 294 | + | title: "Partial outage", | |
| 295 | + | line: incidentLine ?? `Not answering: ${named(down)}${slow.length ? `. Slow: ${named(slow)}` : ""}.`, | |
| 296 | + | }; | |
| 297 | + | } | |
| 298 | + | return { state, title: "Degraded performance", line: `Slow to answer: ${named(slow)}.` }; | |
| 299 | + | } | |
| 300 | + | return { state, title: "Status unavailable", line: "No checks have run yet." }; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /** Open incidents, and those resolved within `days` of `now`, newest first. */ | |
| 304 | + | export function recentIncidents<T extends Pick<StatusIncident, "started_at" | "resolved_at">>( | |
| 305 | + | incidents: T[], | |
| 306 | + | now: Date, | |
| 307 | + | days = HISTORY_DAYS, | |
| 308 | + | ): T[] { | |
| 309 | + | const since = now.getTime() - days * DAY_MS; | |
| 310 | + | return incidents | |
| 311 | + | .filter((i) => i.resolved_at == null || Date.parse(i.resolved_at) >= since) | |
| 312 | + | .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at)); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /** Everything the page and the JSON show. */ | |
| 316 | + | export type PageModel = { | |
| 317 | + | report: StatusReport; | |
| 318 | + | bars: Record<string, DayBar[]>; | |
| 319 | + | /** The checks have not run for a while: the checker itself is in trouble. */ | |
| 320 | + | stale: boolean; | |
| 321 | + | }; | |
| 322 | + | ||
| 323 | + | /** The report, from what is stored. */ | |
| 324 | + | export function buildPage(input: { | |
| 325 | + | parts: ComponentInfo[]; | |
| 326 | + | current: Map<string, Current>; | |
| 327 | + | checkedAt: string | null; | |
| 328 | + | days: DayRow[]; | |
| 329 | + | incidents: StatusIncident[]; | |
| 330 | + | maintenance?: StatusMaintenance[]; | |
| 331 | + | now: Date; | |
| 332 | + | }): PageModel { | |
| 333 | + | const { parts, current, checkedAt, days, now } = input; | |
| 334 | + | const window = lastDays(now); | |
| 335 | + | const since = window[0]!; | |
| 336 | + | const rows = days.filter((r) => r.day >= since); | |
| 337 | + | const incidents = recentIncidents(input.incidents, now); | |
| 338 | + | const open = incidents.filter((i) => i.resolved_at == null); | |
| 339 | + | // Upcoming and under way, soonest first, each with its state as of now. | |
| 340 | + | const maintenance = (input.maintenance ?? []) | |
| 341 | + | .map((m) => ({ ...m, state: maintenanceState(m, now) })) | |
| 342 | + | .filter((m) => m.state === "scheduled" || m.state === "in_progress") | |
| 343 | + | .sort((a, b) => a.starts_at.localeCompare(b.starts_at)); | |
| 344 | + | const working = underMaintenance(maintenance, now); | |
| 345 | + | const components: StatusComponent[] = parts.map((info) => { | |
| 346 | + | const seen = info.check.kind === "none" ? null : current.get(info.key); | |
| 347 | + | const check: StatusComponentState = seen?.state ?? "unmonitored"; | |
| 348 | + | const impacts = open.flatMap((i) => i.component_impacts.filter((c) => c.key === info.key).map((c) => c.impact)); | |
| 349 | + | return { | |
| 350 | + | key: info.key, | |
| 351 | + | name: info.name, | |
| 352 | + | address: info.address, | |
| 353 | + | checks: info.checks, | |
| 354 | + | state: effectiveState(check, impacts, working.has(info.key)), | |
| 355 | + | check_state: check, | |
| 356 | + | detail: seen?.detail ?? (info.check.kind === "none" ? "Not monitored yet" : "Not checked yet"), | |
| 357 | + | latency_ms: seen?.latency_ms ?? null, | |
| 358 | + | uptime_90d: info.check.kind === "none" ? null : uptimeOver(info.key, rows), | |
| 359 | + | }; | |
| 360 | + | }); | |
| 361 | + | const core = new Set(parts.filter((p) => p.core).map((p) => p.key)); | |
| 362 | + | const bars = Object.fromEntries(parts.map((p) => [p.key, barFor(p.key, rows, incidents, window)])); | |
| 363 | + | const inProgress = maintenance.filter((m) => m.state === "in_progress"); | |
| 364 | + | return { | |
| 365 | + | report: { checked_at: checkedAt, overall: summarize(components, core, open, inProgress), components, incidents, maintenance }, | |
| 366 | + | bars, | |
| 367 | + | stale: checkedAt == null || now.getTime() - Date.parse(checkedAt) > STALE_MS, | |
| 368 | + | }; | |
| 369 | + | } |
| 1 | + | /** Font files, bundled as Data modules (see `rules` in wrangler.jsonc). */ | |
| 2 | + | declare module "*.woff2" { | |
| 3 | + | const data: ArrayBuffer; | |
| 4 | + | export default data; | |
| 5 | + | } |
| 1 | + | /** | |
| 2 | + | * A postmortem's starting point: the timeline written out from the | |
| 3 | + | * incident's own, the impact from its parts and times, and the follow-ups | |
| 4 | + | * as action items. Staff edit it before publishing. No Workers imports. | |
| 5 | + | */ | |
| 6 | + | import type { AdminIncident, FollowUp, PostmortemFields, TimelineEntry } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { duration } from "./incidents.ts"; | |
| 9 | + | import { IMPACT_WORD } from "./model.ts"; | |
| 10 | + | ||
| 11 | + | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 12 | + | ||
| 13 | + | /** "5 Oct 14:03 UTC". */ | |
| 14 | + | export function stamp(at: string): string { | |
| 15 | + | const d = new Date(at); | |
| 16 | + | const hh = String(d.getUTCHours()).padStart(2, "0"); | |
| 17 | + | const mm = String(d.getUTCMinutes()).padStart(2, "0"); | |
| 18 | + | return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${hh}:${mm} UTC`; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** Lines not worth a reader's time: acknowledgements, follow-up ticks, the postmortem itself. */ | |
| 22 | + | const SKIP = new Set(["acknowledged", "followup", "postmortem"]); | |
| 23 | + | ||
| 24 | + | /** The internal timeline as a list, one line per entry, public updates marked. */ | |
| 25 | + | export function timelineText(entries: TimelineEntry[]): string { | |
| 26 | + | return entries | |
| 27 | + | .filter((e) => !SKIP.has(e.kind)) | |
| 28 | + | .map((e) => `- ${stamp(e.at)}: ${e.kind === "update" ? "Status page: " : ""}${e.text.replace(/\s*\n+\s*/g, " ")}`) | |
| 29 | + | .join("\n"); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function postmortemDraft( | |
| 33 | + | incident: Pick<AdminIncident, "started_at" | "resolved_at" | "components" | "durations">, | |
| 34 | + | timeline: TimelineEntry[], | |
| 35 | + | followups: FollowUp[], | |
| 36 | + | names: Map<string, string>, | |
| 37 | + | ): PostmortemFields { | |
| 38 | + | const parts = incident.components | |
| 39 | + | .filter((c) => c.impact !== "operational") | |
| 40 | + | .map((c) => `${names.get(c.key) ?? c.key} (${IMPACT_WORD[c.impact].toLowerCase()})`); | |
| 41 | + | const lasted = incident.durations.to_resolve != null ? ` for ${duration(incident.durations.to_resolve)}` : ""; | |
| 42 | + | const window = `from ${stamp(incident.started_at)}${incident.resolved_at ? ` to ${stamp(incident.resolved_at)}` : ""}`; | |
| 43 | + | return { | |
| 44 | + | summary: "", | |
| 45 | + | impact: `${parts.join(", ") || "No part"} affected${lasted}, ${window}.`, | |
| 46 | + | timeline: timelineText(timeline), | |
| 47 | + | root_cause: "", | |
| 48 | + | went_well: "", | |
| 49 | + | went_badly: "", | |
| 50 | + | action_items: followups.map((f) => `- ${f.title}`).join("\n"), | |
| 51 | + | }; | |
| 52 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { combine, runCheck, step } from "./probe.ts"; | |
| 5 | + | ||
| 6 | + | const answer = (status: number) => async () => new Response("x", { status }); | |
| 7 | + | ||
| 8 | + | test("a step works on 2xx, or on exactly the status it expects", async () => { | |
| 9 | + | assert.equal((await step(answer(200), { url: "https://a/" })).ok, true); | |
| 10 | + | const refused = await step(answer(502), { url: "https://a/" }); | |
| 11 | + | assert.deepEqual([refused.ok, refused.error], [false, "HTTP 502"]); | |
| 12 | + | assert.equal((await step(answer(401), { url: "https://a/", expect: 401 })).ok, true); | |
| 13 | + | assert.equal((await step(answer(200), { url: "https://a/", expect: 401 })).ok, false); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("a step that cannot connect, or takes too long, fails with why", async () => { | |
| 17 | + | const broken = await step(async () => Promise.reject(new TypeError("boom")), { url: "https://a/" }); | |
| 18 | + | assert.equal(broken.error, "could not connect"); | |
| 19 | + | const slow = await step( | |
| 20 | + | (_url, init) => new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(new Error("aborted")))), | |
| 21 | + | { url: "https://a/" }, | |
| 22 | + | 20, | |
| 23 | + | ); | |
| 24 | + | assert.equal(slow.error, "timed out"); | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | test("a check's steps: all must work, and it takes as long as the slowest", () => { | |
| 28 | + | assert.deepEqual(combine([{ ok: true, ms: 10 }, { ok: true, ms: 30 }]), { ok: true, ms: 30 }); | |
| 29 | + | assert.deepEqual(combine([{ ok: true, ms: 10 }, { ok: false, ms: 5, error: "HTTP 500" }]), { ok: false, ms: 10, error: "HTTP 500" }); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("runCheck: billing without a binding, and a part with no check, are null", async () => { | |
| 33 | + | const probers = { fetch: answer(200), billing: null }; | |
| 34 | + | assert.equal(await runCheck({ kind: "billing" }, probers), null); | |
| 35 | + | assert.equal(await runCheck({ kind: "none" }, probers), null); | |
| 36 | + | assert.equal((await runCheck({ kind: "billing" }, { ...probers, billing: async () => ({ version: 1 }) }))!.ok, true); | |
| 37 | + | assert.equal((await runCheck({ kind: "billing" }, { ...probers, billing: async () => null }))!.error, "no price book"); | |
| 38 | + | const mixed = await runCheck({ kind: "http", steps: [{ url: "https://a/" }, { url: "https://b/", expect: 401 }] }, probers); | |
| 39 | + | assert.equal(mixed!.ok, false); | |
| 40 | + | }); |
| 1 | + | /** | |
| 2 | + | * Running a part's check: each request it makes, all at once, with a short | |
| 3 | + | * timeout. The answer's body is never read. | |
| 4 | + | */ | |
| 5 | + | import type { Check, Step } from "./components.ts"; | |
| 6 | + | ||
| 7 | + | /** A check's raw outcome. */ | |
| 8 | + | export type ProbeResult = { | |
| 9 | + | ok: boolean; | |
| 10 | + | /** How long it took, in milliseconds: the slowest of its requests. */ | |
| 11 | + | ms: number; | |
| 12 | + | /** Why it failed, in a few words: "timed out", "HTTP 502". */ | |
| 13 | + | error?: string; | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | /** No request waits longer than this. */ | |
| 17 | + | export const TIMEOUT_MS = 5000; | |
| 18 | + | ||
| 19 | + | export const USER_AGENT = "g1t-status (+https://status.g1t.sh)"; | |
| 20 | + | ||
| 21 | + | type Fetch = (url: string, init: RequestInit) => Promise<Response>; | |
| 22 | + | ||
| 23 | + | class Timeout extends Error {} | |
| 24 | + | ||
| 25 | + | /** Runs `work`, timing it, and failing it after `timeoutMs`. */ | |
| 26 | + | export async function timed( | |
| 27 | + | work: (signal: AbortSignal) => Promise<true | string>, | |
| 28 | + | timeoutMs = TIMEOUT_MS, | |
| 29 | + | now: () => number = Date.now, | |
| 30 | + | ): Promise<ProbeResult> { | |
| 31 | + | const started = now(); | |
| 32 | + | const controller = new AbortController(); | |
| 33 | + | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 34 | + | const deadline = new Promise<never>((_, reject) => { | |
| 35 | + | timer = setTimeout(() => { | |
| 36 | + | controller.abort(); | |
| 37 | + | reject(new Timeout()); | |
| 38 | + | }, timeoutMs); | |
| 39 | + | }); | |
| 40 | + | try { | |
| 41 | + | const outcome = await Promise.race([work(controller.signal), deadline]); | |
| 42 | + | const ms = now() - started; | |
| 43 | + | return outcome === true ? { ok: true, ms } : { ok: false, ms, error: outcome }; | |
| 44 | + | } catch (error) { | |
| 45 | + | const ms = now() - started; | |
| 46 | + | return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" }; | |
| 47 | + | } finally { | |
| 48 | + | clearTimeout(timer); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** One request, answered with the status that means it works. */ | |
| 53 | + | export function step(fetcher: Fetch, { url, headers = {}, expect }: Step, timeoutMs = TIMEOUT_MS): Promise<ProbeResult> { | |
| 54 | + | return timed(async (signal) => { | |
| 55 | + | const response = await fetcher(url, { | |
| 56 | + | signal, | |
| 57 | + | redirect: "manual", | |
| 58 | + | headers: { "user-agent": USER_AGENT, "cache-control": "no-cache", ...headers }, | |
| 59 | + | }); | |
| 60 | + | await response.body?.cancel().catch(() => undefined); | |
| 61 | + | const good = expect == null ? response.ok : response.status === expect; | |
| 62 | + | return good || `HTTP ${response.status}`; | |
| 63 | + | }, timeoutMs); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** A check's requests together: it works when every one does, and takes as long as the slowest. */ | |
| 67 | + | export function combine(results: ProbeResult[]): ProbeResult { | |
| 68 | + | const ms = Math.max(0, ...results.map((r) => r.ms)); | |
| 69 | + | const failed = results.find((r) => !r.ok); | |
| 70 | + | return failed ? { ok: false, ms, error: failed.error ?? "no answer" } : { ok: true, ms }; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** What runs a check. `billing` is null when there is no binding to it. */ | |
| 74 | + | export type Probers = { | |
| 75 | + | fetch: Fetch; | |
| 76 | + | billing: (() => Promise<unknown>) | null; | |
| 77 | + | timeoutMs?: number; | |
| 78 | + | }; | |
| 79 | + | ||
| 80 | + | /** Runs one part's check. Null for a part with no check. */ | |
| 81 | + | export async function runCheck(check: Check, probers: Probers): Promise<ProbeResult | null> { | |
| 82 | + | const timeoutMs = probers.timeoutMs ?? TIMEOUT_MS; | |
| 83 | + | switch (check.kind) { | |
| 84 | + | case "http": | |
| 85 | + | return combine(await Promise.all(check.steps.map((s) => step(probers.fetch, s, timeoutMs)))); | |
| 86 | + | case "billing": { | |
| 87 | + | const billing = probers.billing; | |
| 88 | + | if (!billing) return null; | |
| 89 | + | return timed(async () => ((await billing()) ? true : "no price book"), timeoutMs); | |
| 90 | + | } | |
| 91 | + | case "none": | |
| 92 | + | return null; | |
| 93 | + | } | |
| 94 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { StatusIncident, StatusMaintenance } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { components } from "./components.ts"; | |
| 7 | + | import { type DayRow, buildPage } from "./model.ts"; | |
| 8 | + | import { byMonth, dayDetail, escape, prose, renderBadge, renderHistory, renderIncident, renderMaintenance, renderMessage, renderPage, renderSubscribe } from "./render.ts"; | |
| 9 | + | ||
| 10 | + | const NOW = new Date("2026-10-05T12:00:00Z"); | |
| 11 | + | const OPTIONS = { siteUrl: "https://g1t.sh", supportUrl: "https://g1t.sh/support", ogImage: "", selfUrl: "https://status.g1t.sh/", now: NOW, email: true }; | |
| 12 | + | const parts = components( | |
| 13 | + | { | |
| 14 | + | SITE_URL: "https://g1t.sh", | |
| 15 | + | API_URL: "https://api.g1t.sh", | |
| 16 | + | DOCS_URL: "https://docs.g1t.sh", | |
| 17 | + | MODELS_URL: "https://models.g1t.sh", | |
| 18 | + | PROBE_REPO: "flagon-io/g1t", | |
| 19 | + | }, | |
| 20 | + | false, | |
| 21 | + | ); | |
| 22 | + | ||
| 23 | + | function page(states: Record<string, "up" | "degraded" | "down">, incidents: StatusIncident[] = [], maintenance: StatusMaintenance[] = []) { | |
| 24 | + | const current = new Map( | |
| 25 | + | Object.entries(states).map(([k, state]) => [k, { state, detail: state === "down" ? "Failed: HTTP 502" : "Answered in 80 ms", latency_ms: 80 }]), | |
| 26 | + | ); | |
| 27 | + | const days: DayRow[] = [ | |
| 28 | + | { component: "api", day: "2026-10-05", checks: 720, up: 719, degraded: 0, down: 1, latency_total: 71900, latency_count: 719 }, | |
| 29 | + | ]; | |
| 30 | + | return buildPage({ parts, current, checkedAt: "2026-10-05T11:59:40Z", days, incidents, maintenance, now: NOW }); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const INCIDENT: StatusIncident = { | |
| 34 | + | id: "abc", | |
| 35 | + | title: "Sign-in <failing>", | |
| 36 | + | impact: "down", | |
| 37 | + | status: "identified", | |
| 38 | + | components: ["site"], | |
| 39 | + | component_impacts: [{ key: "site", impact: "major_outage" }], | |
| 40 | + | started_at: "2026-10-05T11:40:00Z", | |
| 41 | + | resolved_at: null, | |
| 42 | + | url: "https://status.g1t.sh/incidents/abc", | |
| 43 | + | postmortem_published_at: null, | |
| 44 | + | updates: [{ id: "u1", at: "2026-10-05T11:45:00Z", status: "identified", text: "A bad deploy.\n\nRolling back." }], | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | const MAINT: StatusMaintenance = { | |
| 48 | + | id: "m1", | |
| 49 | + | title: "Database upgrade", | |
| 50 | + | message: "Pushes pause for up to five minutes.", | |
| 51 | + | components: ["git"], | |
| 52 | + | starts_at: "2026-10-06T02:00:00Z", | |
| 53 | + | ends_at: "2026-10-06T03:00:00Z", | |
| 54 | + | state: "scheduled", | |
| 55 | + | url: "https://status.g1t.sh/maintenance/m1", | |
| 56 | + | updates: [{ id: "mu1", at: "2026-10-05T10:00:00Z", text: "Pushes pause for up to five minutes." }], | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | test("escape covers HTML's specials", () => { | |
| 60 | + | assert.equal(escape(`<a href="x">'&'</a>`), "<a href="x">'&'</a>"); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("the page: banner, every part with its bar, and no incidents", () => { | |
| 64 | + | const html = renderPage(page({ site: "up", api: "up", git: "up", docs: "up", agents: "up" }), OPTIONS); | |
| 65 | + | assert.match(html, /<h1>All systems normal<\/h1>/); | |
| 66 | + | for (const name of ["Website and sign-in", "API", "Git and repositories", "Documentation", "Sandboxes"]) assert.ok(html.includes(name), name); | |
| 67 | + | assert.equal((html.match(/<ol class="bar"/g) ?? []).length, 5, "Sandboxes has no bar"); | |
| 68 | + | assert.ok(html.includes("No incidents in the last 14 days")); | |
| 69 | + | assert.ok(html.includes('action="/subscribe"'), "a subscribe form"); | |
| 70 | + | assert.ok(html.includes('href="/feed.xml"')); | |
| 71 | + | assert.match(html, /99\.86% uptime/); | |
| 72 | + | assert.match(html, /aria-label="g1t"/); | |
| 73 | + | assert.doesNotMatch(html, /Happening now/); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test("an outage and an open incident are said at the top, escaped", () => { | |
| 77 | + | const html = renderPage( | |
| 78 | + | page({ site: "down", api: "up", git: "up", docs: "up", agents: "up" }, [ | |
| 79 | + | INCIDENT, | |
| 80 | + | ]), | |
| 81 | + | OPTIONS, | |
| 82 | + | ); | |
| 83 | + | assert.match(html, /<h1>Major outage<\/h1>/); | |
| 84 | + | assert.match(html, /Happening now/); | |
| 85 | + | assert.ok(html.includes("Sign-in <failing>")); | |
| 86 | + | assert.ok(!html.includes("<failing>")); | |
| 87 | + | assert.match(html, /<p>A bad deploy\.<\/p><p>Rolling back\.<\/p>/); | |
| 88 | + | assert.match(html, /Identified · Major outage/); | |
| 89 | + | assert.ok(html.includes('href="/incidents/abc"'), "the title links to its page"); | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | test("a stale page says the checks stopped", () => { | |
| 93 | + | const model = page({ api: "up" }); | |
| 94 | + | const html = renderPage({ ...model, stale: true }, OPTIONS); | |
| 95 | + | assert.match(html, /have not run since/); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test("a day's detail names what happened", () => { | |
| 99 | + | assert.equal( | |
| 100 | + | dayDetail({ day: "2026-10-05", state: "up", uptime: 99.86, checks: 720, failed: 1, slow: 0, avg_ms: 100, incidents: ["Pushes failing"] }), | |
| 101 | + | "5 Oct 2026 · 99.86% answered · 1 failed check of 720 · average 100 ms · Incident: Pushes failing", | |
| 102 | + | ); | |
| 103 | + | assert.equal( | |
| 104 | + | dayDetail({ day: "2026-10-01", state: "none", uptime: null, checks: 0, failed: 0, slow: 0, avg_ms: null, incidents: [] }), | |
| 105 | + | "1 Oct 2026 · No data", | |
| 106 | + | ); | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | test("the badge is an SVG coloured by state", () => { | |
| 110 | + | const svg = renderBadge("degraded", "Partial outage"); | |
| 111 | + | assert.match(svg, /^<svg/); | |
| 112 | + | assert.match(svg, /#ffbd8c/); | |
| 113 | + | assert.match(svg, /partial outage/); | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | test("maintenance: upcoming, then in progress with its parts under maintenance", () => { | |
| 117 | + | const ahead = renderPage(page({ site: "up", api: "up", git: "up" }, [], [MAINT]), OPTIONS); | |
| 118 | + | assert.ok(ahead.includes("Upcoming maintenance") && ahead.includes("Database upgrade") && !ahead.includes("Maintenance in progress")); | |
| 119 | + | const during = { ...OPTIONS, now: new Date("2026-10-06T02:30:00Z") }; | |
| 120 | + | const model = buildPage({ | |
| 121 | + | parts, | |
| 122 | + | current: new Map([["git", { state: "down" as const, detail: "Failed: HTTP 503", latency_ms: null }], ["site", { state: "up" as const, detail: "ok", latency_ms: 1 }]]), | |
| 123 | + | checkedAt: "2026-10-06T02:29:40Z", | |
| 124 | + | days: [], | |
| 125 | + | incidents: [], | |
| 126 | + | maintenance: [MAINT], | |
| 127 | + | now: during.now, | |
| 128 | + | }); | |
| 129 | + | const html = renderPage(model, during); | |
| 130 | + | assert.ok(html.includes("Maintenance in progress")); | |
| 131 | + | assert.ok(html.includes("<h1>Under maintenance</h1>")); | |
| 132 | + | assert.ok(html.includes('class="state s-maintenance"')); | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | test("an incident's page: every update, its parts' impact, and the postmortem once published", () => { | |
| 136 | + | const names = new Map([["site", "Website and sign-in"]]); | |
| 137 | + | const resolved = { ...INCIDENT, status: "resolved" as const, resolved_at: "2026-10-05T12:40:00Z", postmortem_published_at: "2026-10-06T09:00:00Z" }; | |
| 138 | + | const pm = { summary: "A deploy broke sign-in.", impact: "", timeline: "- 11:40 declared\n- 12:40 resolved", root_cause: "A <bad> cookie.", went_well: "", went_badly: "", action_items: "- Canary deploys", published_at: "2026-10-06T09:00:00Z" }; | |
| 139 | + | const html = renderIncident(resolved, pm, names, { ...OPTIONS, selfUrl: resolved.url }); | |
| 140 | + | assert.ok(html.includes('id="update-u1"')); | |
| 141 | + | assert.ok(html.includes("Website and sign-in")); | |
| 142 | + | assert.ok(html.includes("Major outage")); | |
| 143 | + | assert.ok(html.includes('id="postmortem"')); | |
| 144 | + | assert.ok(html.includes("<h3>Root cause</h3>") && html.includes("A <bad> cookie.")); | |
| 145 | + | assert.ok(!html.includes("<h3>Impact</h3>"), "empty sections are left out"); | |
| 146 | + | assert.ok(html.includes("<ul><li>Canary deploys</li></ul>")); | |
| 147 | + | assert.ok(html.includes("lasted 1h 00m")); | |
| 148 | + | const draft = renderIncident(resolved, null, names, OPTIONS); | |
| 149 | + | assert.ok(!draft.includes('id="postmortem"') && draft.includes("If a postmortem is written")); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | test("prose: paragraphs and lists, escaped", () => { | |
| 153 | + | assert.equal(prose("One\ntwo\n\n- a\n- <b>"),"<p>One<br>two</p><ul><li>a</li><li><b></li></ul>"); | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | test("history: months newest first, empty ones said so", () => { | |
| 157 | + | const old = { ...INCIDENT, id: "old", started_at: "2026-08-10T00:00:00Z", resolved_at: "2026-08-10T01:00:00Z" }; | |
| 158 | + | const groups = byMonth([INCIDENT, old], [MAINT]); | |
| 159 | + | assert.deepEqual(groups.map((g) => g.month), ["2026-10", "2026-08"]); | |
| 160 | + | const html = renderHistory([INCIDENT, old], [MAINT], OPTIONS, 3); | |
| 161 | + | assert.ok(html.includes("October 2026") && html.includes("September 2026") && html.includes("August 2026")); | |
| 162 | + | assert.ok(html.includes("No incidents or maintenance.")); | |
| 163 | + | assert.ok(html.indexOf("October 2026") < html.indexOf("August 2026")); | |
| 164 | + | assert.ok(html.includes('href="/maintenance/m1"')); | |
| 165 | + | }); | |
| 166 | + | ||
| 167 | + | test("subscribe pages: the form, its parts, and a confirm button rather than a link that acts", () => { | |
| 168 | + | const html = renderSubscribe(OPTIONS, [{ key: "git", name: "Git" }]); | |
| 169 | + | assert.ok(html.includes('name="components" value="git"') && html.includes('name="website"')); | |
| 170 | + | assert.ok(renderSubscribe({ ...OPTIONS, email: false }, []).includes("not available")); | |
| 171 | + | const confirm = renderMessage(OPTIONS, { title: "Confirm", text: "One more step", form: { action: "/subscribe/confirm", fields: { token: 'a"b' }, button: "Confirm subscription" } }); | |
| 172 | + | assert.ok(confirm.includes('method="post" action="/subscribe/confirm"') && confirm.includes('value="a"b"')); | |
| 173 | + | assert.ok(renderMaintenance(MAINT, new Map([["git", "Git"]]), OPTIONS).includes("Affects Git")); | |
| 174 | + | }); |
| 1 | + | /** | |
| 2 | + | * The pages at status.g1t.sh, drawn on the server as plain HTML: no | |
| 3 | + | * framework, a small stylesheet inline, and a few lines of script (served | |
| 4 | + | * at /status.js) that only add hover and keyboard detail to the bars and | |
| 5 | + | * say times in the reader's own words. Every page works without it: | |
| 6 | + | * subscribing, confirming and leaving are plain forms. No Workers | |
| 7 | + | * imports, so it is tested under Node. | |
| 8 | + | * | |
| 9 | + | * / the status now, maintenance, recent incidents, subscribing | |
| 10 | + | * /incidents/<id> one incident's every public update, and its postmortem | |
| 11 | + | * /maintenance/<id> one maintenance window and its updates | |
| 12 | + | * /history past incidents and maintenance, by month | |
| 13 | + | * /subscribe subscribing by email, to every part or some | |
| 14 | + | */ | |
| 15 | + | import type { PostmortemFields, StatusComponent, StatusIncident, StatusMaintenance, StatusOverallState } from "@g1t/contracts"; | |
| 16 | + | ||
| 17 | + | import { type DayBar, HISTORY_DAYS, IMPACT_WORD, INCIDENT_STATUS, type PageModel, maintenanceState, percent } from "./model.ts"; | |
| 18 | + | ||
| 19 | + | export type PageOptions = { | |
| 20 | + | /** The site the page is about: `https://g1t.sh`. */ | |
| 21 | + | siteUrl: string; | |
| 22 | + | /** Where help is: `https://g1t.sh/support`. */ | |
| 23 | + | supportUrl: string; | |
| 24 | + | /** The share card, or empty for none. */ | |
| 25 | + | ogImage: string; | |
| 26 | + | /** This page's own address, for its canonical link. */ | |
| 27 | + | selfUrl: string; | |
| 28 | + | now: Date; | |
| 29 | + | /** Whether email subscriptions can be offered (a sender and a secret). */ | |
| 30 | + | email?: boolean; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | export function escape(text: string): string { | |
| 34 | + | return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 38 | + | const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; | |
| 39 | + | ||
| 40 | + | /** "5 Oct 2026". */ | |
| 41 | + | export function dayLabel(day: string): string { | |
| 42 | + | const [y, m, d] = day.split("-").map(Number); | |
| 43 | + | return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** "5 Oct 2026, 14:03 UTC": the script says it in the reader's time. */ | |
| 47 | + | export function timeLabel(at: string): string { | |
| 48 | + | const date = new Date(at); | |
| 49 | + | const hh = String(date.getUTCHours()).padStart(2, "0"); | |
| 50 | + | const mm = String(date.getUTCMinutes()).padStart(2, "0"); | |
| 51 | + | return `${dayLabel(date.toISOString().slice(0, 10))}, ${hh}:${mm} UTC`; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function time(at: string, relative = false): string { | |
| 55 | + | return `<time datetime="${escape(at)}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** "1h 05m", "3d 4h". */ | |
| 59 | + | export function lasted(fromIso: string, toIso: string): string { | |
| 60 | + | const minutes = Math.max(0, Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60000)); | |
| 61 | + | if (minutes < 60) return `${minutes} min`; | |
| 62 | + | const hours = Math.floor(minutes / 60); | |
| 63 | + | if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; | |
| 64 | + | return `${Math.floor(hours / 24)}d ${hours % 24}h`; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** What a day's square says on hover, and to a screen reader. */ | |
| 68 | + | export function dayDetail(bar: DayBar): string { | |
| 69 | + | const parts = [dayLabel(bar.day)]; | |
| 70 | + | if (bar.checks === 0) parts.push("No data"); | |
| 71 | + | else { | |
| 72 | + | parts.push(`${percent(bar.uptime)} answered`); | |
| 73 | + | if (bar.failed > 0) parts.push(`${bar.failed} failed check${bar.failed === 1 ? "" : "s"} of ${bar.checks}`); | |
| 74 | + | if (bar.slow > 0) parts.push(`${bar.slow} slow`); | |
| 75 | + | if (bar.avg_ms != null) parts.push(`average ${bar.avg_ms} ms`); | |
| 76 | + | } | |
| 77 | + | for (const title of bar.incidents) parts.push(`Incident: ${title}`); | |
| 78 | + | return parts.join(" · "); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | export const STATE_WORD: Record<StatusComponent["state"], string> = { | |
| 82 | + | up: "Operational", | |
| 83 | + | degraded: "Degraded", | |
| 84 | + | partial: "Partial outage", | |
| 85 | + | down: "Outage", | |
| 86 | + | maintenance: "Under maintenance", | |
| 87 | + | unmonitored: "Not monitored", | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | /** The pixel G1T: 5×7 capitals, the 1 in lavender, as in the site's logo. */ | |
| 91 | + | const G = [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."]; | |
| 92 | + | const ONE = ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####"]; | |
| 93 | + | const T = ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."]; | |
| 94 | + | ||
| 95 | + | function pixels(rows: string[], dx: number, cls: string): string { | |
| 96 | + | return rows | |
| 97 | + | .flatMap((row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${(dx + x) * 10 + 1}" y="${y * 10 + 1}" width="8" height="8"${cls}/>`] : []))) | |
| 98 | + | .join(""); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | export const LOGO = `<svg class="logo" viewBox="1 1 148 68" role="img" aria-label="g1t">${pixels(G, 0, "")}${pixels(ONE, 6, ' class="one"')}${pixels(T, 10, "")}</svg>`; | |
| 102 | + | ||
| 103 | + | /** The favicon: the pixel 1. */ | |
| 104 | + | export const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><style>rect{fill:#6b56e8}@media (prefers-color-scheme: dark){rect{fill:#b6a8ff}}</style>${ONE.flatMap( | |
| 105 | + | (row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${x * 2 + 3}" y="${y * 2 + 1}" width="2" height="2"/>`] : [])), | |
| 106 | + | ).join("")}</svg>`; | |
| 107 | + | ||
| 108 | + | function bar(component: StatusComponent, days: DayBar[]): string { | |
| 109 | + | if (component.check_state === "unmonitored" && component.uptime_90d == null) { | |
| 110 | + | return `<p class="unchecked">${escape(component.checks)}</p>`; | |
| 111 | + | } | |
| 112 | + | const cells = days | |
| 113 | + | .map((d) => { | |
| 114 | + | const detail = dayDetail(d); | |
| 115 | + | return `<li class="${d.state}" title="${escape(detail)}" data-detail="${escape(detail)}"></li>`; | |
| 116 | + | }) | |
| 117 | + | .join(""); | |
| 118 | + | const label = `${component.name}: ${percent(component.uptime_90d)} of checks answered over ${HISTORY_DAYS} days. Use the arrow keys for each day.`; | |
| 119 | + | return `<div class="history"> | |
| 120 | + | <ol class="bar" tabindex="0" aria-label="${escape(label)}">${cells}</ol> | |
| 121 | + | <div class="legend"><span><span class="long">${HISTORY_DAYS}</span><span class="short">30</span> days ago</span><span class="uptime">${escape(percent(component.uptime_90d))} uptime</span><span>Today</span></div> | |
| 122 | + | <p class="detail" aria-live="polite"></p> | |
| 123 | + | </div>`; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | function componentRow(component: StatusComponent, days: DayBar[]): string { | |
| 127 | + | const seen = component.check_state !== "unmonitored" ? `<p class="seen">${escape(component.detail)}</p>` : ""; | |
| 128 | + | return `<li class="component"> | |
| 129 | + | <div class="row"> | |
| 130 | + | <div class="name"><h3>${escape(component.name)}</h3><span class="address">${escape(component.address)}</span></div> | |
| 131 | + | <div class="state s-${component.state}"><span class="dot ${component.state}" aria-hidden="true"></span>${STATE_WORD[component.state]}</div> | |
| 132 | + | </div> | |
| 133 | + | ${seen} | |
| 134 | + | ${bar(component, days)} | |
| 135 | + | </li>`; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /** | |
| 139 | + | * Text as HTML: blank lines between paragraphs, and a block whose lines | |
| 140 | + | * all start with "- " as a list. Everything is escaped. | |
| 141 | + | */ | |
| 142 | + | export function prose(text: string): string { | |
| 143 | + | return text | |
| 144 | + | .split(/\n{2,}/) | |
| 145 | + | .map((block) => { | |
| 146 | + | const lines = block.split("\n").filter((l) => l.trim() !== ""); | |
| 147 | + | if (lines.length && lines.every((l) => /^\s*[-*] /.test(l))) { | |
| 148 | + | return `<ul>${lines.map((l) => `<li>${escape(l.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`; | |
| 149 | + | } | |
| 150 | + | return `<p>${escape(block).replace(/\n/g, "<br>")}</p>`; | |
| 151 | + | }) | |
| 152 | + | .join(""); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | function impactLine(incident: StatusIncident, names: Map<string, string>): string { | |
| 156 | + | return incident.component_impacts | |
| 157 | + | .map((c) => `<span class="affects">${escape(names.get(c.key) ?? c.key)} <span class="impact i-${c.impact}">${escape(IMPACT_WORD[c.impact])}</span></span>`) | |
| 158 | + | .join(""); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | function pill(incident: StatusIncident): string { | |
| 162 | + | const open = incident.resolved_at == null; | |
| 163 | + | const impact = incident.impact === "down" ? "Major outage" : "Partial outage"; | |
| 164 | + | return `<span class="pill ${open ? incident.impact : "done"}">${open ? `${escape(INCIDENT_STATUS[incident.status])} · ${impact}` : "Resolved"}</span>`; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | function updatesList(incident: StatusIncident): string { | |
| 168 | + | return `<ol class="updates">${incident.updates | |
| 169 | + | .map( | |
| 170 | + | (u) => | |
| 171 | + | `<li id="update-${escape(u.id)}"><p class="when"><strong>${escape(INCIDENT_STATUS[u.status])}</strong> ${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`, | |
| 172 | + | ) | |
| 173 | + | .join("")}</ol>`; | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /** An incident on the front page: its title links to its own page. */ | |
| 177 | + | function incidentBlock(incident: StatusIncident, names: Map<string, string>): string { | |
| 178 | + | const open = incident.resolved_at == null; | |
| 179 | + | return `<article class="incident ${open ? `open ${incident.impact}` : "resolved"}"> | |
| 180 | + | <header> | |
| 181 | + | <h3><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a></h3> | |
| 182 | + | ${pill(incident)} | |
| 183 | + | </header> | |
| 184 | + | <p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)}` : ""}</p> | |
| 185 | + | <div class="impacts">${impactLine(incident, names)}</div> | |
| 186 | + | ${updatesList(incident)} | |
| 187 | + | </article>`; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** A line for a past incident: title, when, how long, and its postmortem. */ | |
| 191 | + | function incidentLine(incident: StatusIncident): string { | |
| 192 | + | const how = incident.resolved_at ? `lasted ${lasted(incident.started_at, incident.resolved_at)}` : escape(INCIDENT_STATUS[incident.status]); | |
| 193 | + | const latest = incident.updates[0]; | |
| 194 | + | return `<li class="line"> | |
| 195 | + | <div class="line-head"><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a>${incident.postmortem_published_at ? `<a class="tag" href="/incidents/${escape(incident.id)}#postmortem">Postmortem</a>` : ""}</div> | |
| 196 | + | <p class="meta">${time(incident.started_at)} · ${how}</p> | |
| 197 | + | ${latest ? `<p class="last">${escape(latest.text.split("\n")[0]!.slice(0, 220))}</p>` : ""} | |
| 198 | + | </li>`; | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | function maintenanceWindow(m: StatusMaintenance): string { | |
| 202 | + | return `${time(m.starts_at)} to ${time(m.ends_at)} · ${lasted(m.starts_at, m.ends_at)}`; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | const MAINTENANCE_WORD: Record<StatusMaintenance["state"], string> = { | |
| 206 | + | scheduled: "Scheduled", | |
| 207 | + | in_progress: "In progress", | |
| 208 | + | completed: "Completed", | |
| 209 | + | cancelled: "Cancelled", | |
| 210 | + | }; | |
| 211 | + | ||
| 212 | + | function maintenanceBlock(m: StatusMaintenance, names: Map<string, string>, now: Date): string { | |
| 213 | + | const state = maintenanceState(m, now); | |
| 214 | + | const latest = m.updates[0]; | |
| 215 | + | return `<article class="incident maint ${state}"> | |
| 216 | + | <header> | |
| 217 | + | <h3><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a></h3> | |
| 218 | + | <span class="pill maint">${MAINTENANCE_WORD[state]}</span> | |
| 219 | + | </header> | |
| 220 | + | <p class="meta">${maintenanceWindow(m)}</p> | |
| 221 | + | <p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p> | |
| 222 | + | <div class="text">${prose(m.message)}</div> | |
| 223 | + | ${latest && latest.text !== m.message ? `<p class="when latest"><strong>Latest</strong> ${time(latest.at)} · ${escape(latest.text.split("\n")[0]!)}</p>` : ""} | |
| 224 | + | </article>`; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | const BANNER_CLASS: Record<StatusOverallState, string> = { up: "up", degraded: "degraded", down: "down", maintenance: "maintenance", unknown: "unknown" }; | |
| 228 | + | ||
| 229 | + | type Layout = { title: string; description: string; body: string; live?: boolean; feeds?: boolean }; | |
| 230 | + | ||
| 231 | + | function layout(options: PageOptions, page: Layout): string { | |
| 232 | + | const site = options.siteUrl.replace(/\/+$/, ""); | |
| 233 | + | const siteHost = site.replace(/^https?:\/\//, ""); | |
| 234 | + | return `<!doctype html> | |
| 235 | + | <html lang="en"> | |
| 236 | + | <head> | |
| 237 | + | <meta charset="utf-8"> | |
| 238 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 239 | + | <title>${escape(page.title)}</title> | |
| 240 | + | <meta name="description" content="${escape(page.description)}"> | |
| 241 | + | <meta name="theme-color" content="#0f0f11"> | |
| 242 | + | <meta name="color-scheme" content="dark"> | |
| 243 | + | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> | |
| 244 | + | <link rel="canonical" href="${escape(options.selfUrl)}"> | |
| 245 | + | <link rel="alternate" type="application/json" href="/status.json"> | |
| 246 | + | <link rel="alternate" type="application/atom+xml" title="g1t status" href="/feed.xml"> | |
| 247 | + | <link rel="alternate" type="application/feed+json" title="g1t status" href="/feed.json"> | |
| 248 | + | <link rel="preload" href="/fonts/hanken-grotesk.woff2" as="font" type="font/woff2" crossorigin> | |
| 249 | + | <link rel="preload" href="/fonts/bricolage-grotesque.woff2" as="font" type="font/woff2" crossorigin> | |
| 250 | + | <meta property="og:site_name" content="g1t"> | |
| 251 | + | <meta property="og:type" content="website"> | |
| 252 | + | <meta property="og:url" content="${escape(options.selfUrl)}"> | |
| 253 | + | <meta property="og:title" content="${escape(page.title)}"> | |
| 254 | + | <meta property="og:description" content="${escape(page.description)}"> | |
| 255 | + | ${options.ogImage ? `<meta property="og:image" content="${escape(options.ogImage)}"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="${escape(options.ogImage)}">` : ""} | |
| 256 | + | <style>${STYLE}</style> | |
| 257 | + | <script src="/status.js" defer></script> | |
| 258 | + | </head> | |
| 259 | + | <body${page.live ? " data-live" : ""}> | |
| 260 | + | <div class="page"> | |
| 261 | + | <header class="top"> | |
| 262 | + | <a class="brand" href="/" aria-label="g1t status">${LOGO}<span>Status</span></a> | |
| 263 | + | <a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a> | |
| 264 | + | </header> | |
| 265 | + | <main> | |
| 266 | + | ${page.body} | |
| 267 | + | </main> | |
| 268 | + | <footer class="foot"> | |
| 269 | + | <a href="/history">History</a><span aria-hidden="true">·</span><a href="/subscribe">Subscribe</a><span aria-hidden="true">·</span><a href="/feed.xml">Atom feed</a><span aria-hidden="true">·</span><a href="/feed.json">JSON feed</a><span aria-hidden="true">·</span><a href="/status.json">status.json</a><span aria-hidden="true">·</span><a href="${escape(options.supportUrl)}">Support</a><span aria-hidden="true">·</span><a href="${escape(site)}/">${escape(siteHost)}</a> | |
| 270 | + | </footer> | |
| 271 | + | </div> | |
| 272 | + | </body> | |
| 273 | + | </html>`; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | const FEEDS = `the <a href="/feed.xml">Atom feed</a> or the <a href="/feed.json">JSON feed</a>`; | |
| 277 | + | ||
| 278 | + | function subscribeForm(options: PageOptions, compact: boolean): string { | |
| 279 | + | if (!options.email) return `<p class="muted">Email updates are not available here. Follow ${FEEDS} instead.</p>`; | |
| 280 | + | return `<form class="subscribe" method="post" action="/subscribe"> | |
| 281 | + | <label class="sr" for="sub-email">Email address</label> | |
| 282 | + | <input id="sub-email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com"> | |
| 283 | + | <input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true"> | |
| 284 | + | <button type="submit">Subscribe</button> | |
| 285 | + | </form> | |
| 286 | + | <p class="feeds">Get an email when an incident or planned maintenance is posted${compact ? `, or <a href="/subscribe">choose which parts</a>` : ""}. Or follow ${FEEDS}.</p>`; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /** The front page. */ | |
| 290 | + | export function renderPage(model: PageModel, options: PageOptions): string { | |
| 291 | + | const { report, bars, stale } = model; | |
| 292 | + | const { overall, components, incidents, maintenance, checked_at } = report; | |
| 293 | + | const names = new Map(components.map((c) => [c.key, c.name])); | |
| 294 | + | const open = incidents.filter((i) => i.resolved_at == null); | |
| 295 | + | const recentSince = options.now.getTime() - 14 * 24 * 60 * 60 * 1000; | |
| 296 | + | const past = incidents.filter((i) => i.resolved_at != null && Date.parse(i.resolved_at) >= recentSince); | |
| 297 | + | const inProgress = maintenance.filter((m) => maintenanceState(m, options.now) === "in_progress"); | |
| 298 | + | const upcoming = maintenance.filter((m) => maintenanceState(m, options.now) === "scheduled"); | |
| 299 | + | const description = `${overall.title}. Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing, with 90 days of history.`; | |
| 300 | + | const checkedLine = checked_at | |
| 301 | + | ? stale | |
| 302 | + | ? `The checks have not run since ${time(checked_at, true)}, so what follows may be out of date.` | |
| 303 | + | : `Checked ${time(checked_at, true)}, and every minute.` | |
| 304 | + | : "Waiting for the first checks."; | |
| 305 | + | const body = `<section class="banner ${BANNER_CLASS[overall.state]}" aria-live="polite"> | |
| 306 | + | <span class="dot big ${overall.state}" aria-hidden="true"></span> | |
| 307 | + | <div> | |
| 308 | + | <h1>${escape(overall.title)}</h1> | |
| 309 | + | <p>${overall.state === "up" ? "" : `${escape(overall.line)} `}${checkedLine}</p> | |
| 310 | + | </div> | |
| 311 | + | </section> | |
| 312 | + | ${open.length ? `<section class="block"><h2>Happening now</h2>${open.map((i) => incidentBlock(i, names)).join("")}</section>` : ""} | |
| 313 | + | ${inProgress.length ? `<section class="block"><h2>Maintenance in progress</h2>${inProgress.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""} | |
| 314 | + | ${upcoming.length ? `<section class="block"><h2>Upcoming maintenance</h2>${upcoming.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""} | |
| 315 | + | <section class="block"> | |
| 316 | + | <h2>Components</h2> | |
| 317 | + | <ul class="components">${components.map((c) => componentRow(c, bars[c.key] ?? [])).join("")}</ul> | |
| 318 | + | </section> | |
| 319 | + | <section class="block"> | |
| 320 | + | <div class="block-head"><h2>Past incidents</h2><a href="/history">Full history <span aria-hidden="true">→</span></a></div> | |
| 321 | + | ${past.length ? `<ul class="lines">${past.map(incidentLine).join("")}</ul>` : `<p class="empty">No incidents in the last 14 days.</p>`} | |
| 322 | + | </section> | |
| 323 | + | <section class="block" id="subscribe"> | |
| 324 | + | <h2>Subscribe to updates</h2> | |
| 325 | + | <div class="card">${subscribeForm(options, true)}</div> | |
| 326 | + | </section> | |
| 327 | + | <section class="block about"> | |
| 328 | + | <h2>About these checks</h2> | |
| 329 | + | <p>Each part is checked every minute with one quick request, the same a visitor's would make, from outside g1t. A part that answers in over 1.5 seconds counts as slow; one that fails, or takes over 5 seconds, as down. A day shows green when at least 99.9% of its checks answered; checks during planned maintenance are not counted. This page runs apart from g1t itself, so it stays up when g1t does not.</p> | |
| 330 | + | <dl>${components.map((c) => `<dt>${escape(c.name)}</dt><dd>${escape(c.checks)}</dd>`).join("")}</dl> | |
| 331 | + | </section>`; | |
| 332 | + | return layout(options, { title: `${overall.title} · g1t status`, description, body, live: true }); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string }[] = [ | |
| 336 | + | { key: "summary", title: "Summary" }, | |
| 337 | + | { key: "impact", title: "Impact" }, | |
| 338 | + | { key: "timeline", title: "Timeline" }, | |
| 339 | + | { key: "root_cause", title: "Root cause" }, | |
| 340 | + | { key: "went_well", title: "What went well" }, | |
| 341 | + | { key: "went_badly", title: "What went badly" }, | |
| 342 | + | { key: "action_items", title: "Action items" }, | |
| 343 | + | ]; | |
| 344 | + | ||
| 345 | + | /** A postmortem's sections, those with something in them. */ | |
| 346 | + | export function postmortemHtml(p: PostmortemFields): string { | |
| 347 | + | return POSTMORTEM_SECTIONS.filter((s) => p[s.key].trim()) | |
| 348 | + | .map((s) => `<section class="pm-section"><h3>${s.title}</h3><div class="text">${prose(p[s.key])}</div></section>`) | |
| 349 | + | .join(""); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /** One incident's page: every public update, oldest last, and its postmortem once published. */ | |
| 353 | + | export function renderIncident( | |
| 354 | + | incident: StatusIncident, | |
| 355 | + | postmortem: (PostmortemFields & { published_at: string | null }) | null, | |
| 356 | + | names: Map<string, string>, | |
| 357 | + | options: PageOptions, | |
| 358 | + | ): string { | |
| 359 | + | const open = incident.resolved_at == null; | |
| 360 | + | const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p> | |
| 361 | + | <article class="incident-page"> | |
| 362 | + | <header class="page-head"> | |
| 363 | + | <h1>${escape(incident.title)}</h1> | |
| 364 | + | ${pill(incident)} | |
| 365 | + | </header> | |
| 366 | + | <p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)} · lasted ${lasted(incident.started_at, incident.resolved_at)}` : ""}</p> | |
| 367 | + | <div class="impacts">${impactLine(incident, names)}</div> | |
| 368 | + | ${postmortem?.published_at ? `<p class="note">A postmortem was published ${time(postmortem.published_at)}. <a href="#postmortem">Read it</a>.</p>` : ""} | |
| 369 | + | <section class="block"><h2>Updates</h2><div class="card">${updatesList(incident)}</div></section> | |
| 370 | + | ${ | |
| 371 | + | postmortem?.published_at | |
| 372 | + | ? `<section class="block" id="postmortem"><h2>Postmortem</h2><div class="card postmortem">${postmortemHtml(postmortem)}</div></section>` | |
| 373 | + | : !open | |
| 374 | + | ? `<p class="muted">If a postmortem is written for this incident, it will appear on this page.</p>` | |
| 375 | + | : "" | |
| 376 | + | } | |
| 377 | + | </article>`; | |
| 378 | + | return layout(options, { | |
| 379 | + | title: `${incident.title} · g1t status`, | |
| 380 | + | description: `${open ? INCIDENT_STATUS[incident.status] : "Resolved"}: ${incident.title}. ${incident.updates[0]?.text.slice(0, 140) ?? ""}`, | |
| 381 | + | body, | |
| 382 | + | }); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | export function renderMaintenance(m: StatusMaintenance, names: Map<string, string>, options: PageOptions): string { | |
| 386 | + | const state = maintenanceState(m, options.now); | |
| 387 | + | const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p> | |
| 388 | + | <header class="page-head"><h1>${escape(m.title)}</h1><span class="pill maint">${MAINTENANCE_WORD[state]}</span></header> | |
| 389 | + | <p class="meta">${maintenanceWindow(m)}</p> | |
| 390 | + | <p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p> | |
| 391 | + | <section class="block"><h2>Updates</h2><div class="card"><ol class="updates">${m.updates | |
| 392 | + | .map((u) => `<li id="update-${escape(u.id)}"><p class="when">${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`) | |
| 393 | + | .join("")}</ol></div></section>`; | |
| 394 | + | return layout(options, { title: `${m.title} · g1t status`, description: `Planned maintenance: ${m.title}.`, body }); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /** Months, newest first, each with its incidents and maintenance. */ | |
| 398 | + | export function byMonth(incidents: StatusIncident[], maintenance: StatusMaintenance[]): { month: string; incidents: StatusIncident[]; maintenance: StatusMaintenance[] }[] { | |
| 399 | + | const months = new Map<string, { incidents: StatusIncident[]; maintenance: StatusMaintenance[] }>(); | |
| 400 | + | const slot = (month: string) => months.get(month) ?? months.set(month, { incidents: [], maintenance: [] }).get(month)!; | |
| 401 | + | for (const i of incidents) slot(i.started_at.slice(0, 7)).incidents.push(i); | |
| 402 | + | for (const m of maintenance) slot(m.starts_at.slice(0, 7)).maintenance.push(m); | |
| 403 | + | return [...months] | |
| 404 | + | .sort((a, b) => b[0].localeCompare(a[0])) | |
| 405 | + | .map(([month, v]) => ({ | |
| 406 | + | month, | |
| 407 | + | incidents: v.incidents.sort((a, b) => b.started_at.localeCompare(a.started_at)), | |
| 408 | + | maintenance: v.maintenance.sort((a, b) => b.starts_at.localeCompare(a.starts_at)), | |
| 409 | + | })); | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | export function renderHistory(incidents: StatusIncident[], maintenance: StatusMaintenance[], options: PageOptions, months = 12): string { | |
| 413 | + | const groups = byMonth(incidents, maintenance); | |
| 414 | + | const now = options.now; | |
| 415 | + | const list: string[] = []; | |
| 416 | + | for (let i = 0; i < months; i++) { | |
| 417 | + | const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); | |
| 418 | + | const key = d.toISOString().slice(0, 7); | |
| 419 | + | const group = groups.find((g) => g.month === key); | |
| 420 | + | const items = [ | |
| 421 | + | ...(group?.incidents.map(incidentLine) ?? []), | |
| 422 | + | ...(group?.maintenance.map( | |
| 423 | + | (m) => | |
| 424 | + | `<li class="line"><div class="line-head"><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a><span class="tag">Maintenance · ${MAINTENANCE_WORD[maintenanceState(m, now)]}</span></div><p class="meta">${maintenanceWindow(m)}</p></li>`, | |
| 425 | + | ) ?? []), | |
| 426 | + | ]; | |
| 427 | + | list.push(`<section class="month"><h2>${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}</h2>${items.length ? `<ul class="lines">${items.join("")}</ul>` : `<p class="none">No incidents or maintenance.</p>`}</section>`); | |
| 428 | + | } | |
| 429 | + | const body = `<p class="crumbs"><a href="/">Status</a></p> | |
| 430 | + | <header class="page-head"><h1>History</h1></header> | |
| 431 | + | <p class="muted">Every incident and planned maintenance of the last ${months} months, newest first.</p> | |
| 432 | + | ${list.join("")}`; | |
| 433 | + | return layout(options, { title: "History · g1t status", description: "Past incidents and maintenance on g1t, by month.", body }); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | export function renderSubscribe(options: PageOptions, parts: { key: string; name: string }[]): string { | |
| 437 | + | const form = options.email | |
| 438 | + | ? `<form class="stack" method="post" action="/subscribe"> | |
| 439 | + | <label for="email">Email address</label> | |
| 440 | + | <input id="email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com"> | |
| 441 | + | <input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true"> | |
| 442 | + | <fieldset> | |
| 443 | + | <legend>Which parts</legend> | |
| 444 | + | <p class="muted">Leave every box clear to hear about everything.</p> | |
| 445 | + | <div class="checks">${parts.map((p) => `<label class="check"><input type="checkbox" name="components" value="${escape(p.key)}"> ${escape(p.name)}</label>`).join("")}</div> | |
| 446 | + | </fieldset> | |
| 447 | + | <button type="submit">Subscribe</button> | |
| 448 | + | <p class="muted">We email you a link to confirm first. Every email has a link to unsubscribe. Your address is used for nothing else.</p> | |
| 449 | + | </form>` | |
| 450 | + | : `<p class="muted">Email updates are not available on this installation.</p>`; | |
| 451 | + | const body = `<p class="crumbs"><a href="/">Status</a></p> | |
| 452 | + | <header class="page-head"><h1>Subscribe to updates</h1></header> | |
| 453 | + | <div class="card">${form}</div> | |
| 454 | + | <section class="block"><h2>Feeds</h2><div class="card"><p class="muted">Every public update and maintenance notice, newest first: the <a href="/feed.xml">Atom feed</a> for feed readers and chat apps, the <a href="/feed.json">JSON feed</a>, and <a href="/status.json">status.json</a> for the state right now.</p></div></section>`; | |
| 455 | + | return layout(options, { title: "Subscribe · g1t status", description: "Get an email when g1t posts an incident or planned maintenance.", body }); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | /** A short page: a heading, a line, and perhaps a form with one button. */ | |
| 459 | + | export function renderMessage( | |
| 460 | + | options: PageOptions, | |
| 461 | + | input: { title: string; text: string; form?: { action: string; fields: Record<string, string>; button: string } }, | |
| 462 | + | ): string { | |
| 463 | + | const form = input.form | |
| 464 | + | ? `<form method="post" action="${escape(input.form.action)}">${Object.entries(input.form.fields) | |
| 465 | + | .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`) | |
| 466 | + | .join("")}<button type="submit">${escape(input.form.button)}</button></form>` | |
| 467 | + | : `<p><a href="/">Back to the status page</a></p>`; | |
| 468 | + | const body = `<p class="crumbs"><a href="/">Status</a></p> | |
| 469 | + | <section class="card message"><h1>${escape(input.title)}</h1><p>${escape(input.text)}</p>${form}</section>`; | |
| 470 | + | return layout(options, { title: `${input.title} · g1t status`, description: input.text, body }); | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | /** A small badge: "g1t | all systems normal", coloured by state. */ | |
| 474 | + | export function renderBadge(state: StatusOverallState, title: string): string { | |
| 475 | + | const color = { up: "#86efc4", degraded: "#ffbd8c", down: "#ff8394", maintenance: "#8ec5ff", unknown: "#86868e" }[state]; | |
| 476 | + | const label = "g1t"; | |
| 477 | + | const value = title.toLowerCase(); | |
| 478 | + | // Widths from an average glyph of 6.5px at 11px: close enough to centre. | |
| 479 | + | const left = Math.round(label.length * 6.5 + 14); | |
| 480 | + | const right = Math.round(value.length * 6.2 + 14); | |
| 481 | + | const width = left + right; | |
| 482 | + | return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escape(`${label}: ${value}`)}"> | |
| 483 | + | <title>${escape(`${label}: ${value}`)}</title> | |
| 484 | + | <clipPath id="r"><rect width="${width}" height="20" rx="4"/></clipPath> | |
| 485 | + | <g clip-path="url(#r)"><rect width="${left}" height="20" fill="#1e1e21"/><rect x="${left}" width="${right}" height="20" fill="${color}"/></g> | |
| 486 | + | <g font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"> | |
| 487 | + | <text x="${left / 2}" y="14" fill="#ededef">${escape(label)}</text> | |
| 488 | + | <text x="${left + right / 2}" y="14" fill="#0f0f11">${escape(value)}</text> | |
| 489 | + | </g></svg>`; | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | /** The page's script: day detail on hover, tap and arrow keys; times in the reader's words. */ | |
| 493 | + | export const SCRIPT = `(() => { | |
| 494 | + | const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); | |
| 495 | + | const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }); | |
| 496 | + | function ago(at) { | |
| 497 | + | const s = (Date.parse(at) - Date.now()) / 1000; | |
| 498 | + | const a = Math.abs(s); | |
| 499 | + | if (a < 45) return rtf.format(Math.round(s), "second"); | |
| 500 | + | if (a < 2700) return rtf.format(Math.round(s / 60), "minute"); | |
| 501 | + | if (a < 79200) return rtf.format(Math.round(s / 3600), "hour"); | |
| 502 | + | return rtf.format(Math.round(s / 86400), "day"); | |
| 503 | + | } | |
| 504 | + | function times() { | |
| 505 | + | for (const t of document.querySelectorAll("time[datetime]")) { | |
| 506 | + | const at = t.getAttribute("datetime"); | |
| 507 | + | t.title = fmt.format(new Date(at)); | |
| 508 | + | t.textContent = t.hasAttribute("data-relative") ? ago(at) : fmt.format(new Date(at)); | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | times(); | |
| 512 | + | setInterval(times, 30000); | |
| 513 | + | for (const bar of document.querySelectorAll(".bar")) { | |
| 514 | + | const detail = bar.parentElement.querySelector(".detail"); | |
| 515 | + | const days = [...bar.children]; | |
| 516 | + | let at = -1; | |
| 517 | + | function show(i) { | |
| 518 | + | days.forEach((d, j) => d.classList.toggle("on", j === i)); | |
| 519 | + | at = i; | |
| 520 | + | detail.textContent = i >= 0 ? days[i].dataset.detail : ""; | |
| 521 | + | } | |
| 522 | + | bar.addEventListener("pointerover", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); }); | |
| 523 | + | bar.addEventListener("click", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); }); | |
| 524 | + | bar.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && document.activeElement !== bar) show(-1); }); | |
| 525 | + | bar.addEventListener("blur", () => show(-1)); | |
| 526 | + | bar.addEventListener("keydown", (e) => { | |
| 527 | + | const visible = days.filter((d) => d.offsetParent !== null); | |
| 528 | + | const first = days.indexOf(visible[0]); | |
| 529 | + | if (e.key === "ArrowLeft" || e.key === "ArrowRight") { | |
| 530 | + | e.preventDefault(); | |
| 531 | + | const next = at < 0 ? days.length - 1 : at + (e.key === "ArrowLeft" ? -1 : 1); | |
| 532 | + | show(Math.min(days.length - 1, Math.max(first, next))); | |
| 533 | + | } else if (e.key === "Home") { e.preventDefault(); show(first); } | |
| 534 | + | else if (e.key === "End") { e.preventDefault(); show(days.length - 1); } | |
| 535 | + | }); | |
| 536 | + | bar.addEventListener("focus", () => { if (at < 0) show(days.length - 1); }); | |
| 537 | + | } | |
| 538 | + | // Fresh numbers without a reload, while the front page is in view and no one is typing. | |
| 539 | + | if (document.body.hasAttribute("data-live")) { | |
| 540 | + | setInterval(() => { | |
| 541 | + | const busy = document.querySelector(".bar:focus") || (document.activeElement && document.activeElement.tagName === "INPUT"); | |
| 542 | + | if (document.visibilityState === "visible" && !busy) location.reload(); | |
| 543 | + | }, 120000); | |
| 544 | + | } | |
| 545 | + | })(); | |
| 546 | + | `; | |
| 547 | + | ||
| 548 | + | const STYLE = ` | |
| 549 | + | @font-face{font-family:"Hanken Grotesk";font-weight:400 700;font-display:swap;src:url(/fonts/hanken-grotesk.woff2) format("woff2")} | |
| 550 | + | @font-face{font-family:"Bricolage Grotesque";font-weight:500 700;font-display:swap;src:url(/fonts/bricolage-grotesque.woff2) format("woff2")} | |
| 551 | + | @font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono.woff2) format("woff2")} | |
| 552 | + | :root{--bg:#0f0f11;--surface:#161618;--raised:#1e1e21;--line:#28282c;--line-strong:#38383e;--fg:#ededef;--soft:#dcdce0;--muted:#a0a0a8;--faint:#86868e; | |
| 553 | + | --up:#86efc4;--warn:#ffbd8c;--down:#ff8394;--lav:#b6a8ff;--info:#8ec5ff; | |
| 554 | + | --sans:"Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--display:"Bricolage Grotesque",ui-sans-serif,system-ui,sans-serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color-scheme:dark} | |
| 555 | + | *{box-sizing:border-box} | |
| 556 | + | html{-webkit-text-size-adjust:100%} | |
| 557 | + | body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased} | |
| 558 | + | a{color:inherit} | |
| 559 | + | a:focus-visible,.bar:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--lav);outline-offset:3px;border-radius:4px} | |
| 560 | + | .page{max-width:760px;margin:0 auto;padding:0 16px 48px} | |
| 561 | + | .top{display:flex;align-items:center;justify-content:space-between;gap:16px;height:72px;border-bottom:1px solid var(--line)} | |
| 562 | + | .brand{display:flex;align-items:center;gap:12px;text-decoration:none} | |
| 563 | + | .brand span{font:600 20px/1 var(--display);letter-spacing:-.01em;color:var(--muted);padding-left:12px;border-left:1px solid var(--line-strong)} | |
| 564 | + | .logo{height:22px;width:auto;display:block}.logo rect{fill:var(--fg)}.logo rect.one{fill:var(--lav)} | |
| 565 | + | .out{font-size:13px;color:var(--muted);text-decoration:none}.out:hover{color:var(--fg)} | |
| 566 | + | .banner{display:flex;gap:14px;align-items:flex-start;margin-top:32px;padding:20px 22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)} | |
| 567 | + | .banner.up{border-color:rgba(134,239,196,.28);background:linear-gradient(0deg,rgba(134,239,196,.04),rgba(134,239,196,.04)),var(--surface)} | |
| 568 | + | .banner.degraded{border-color:rgba(255,189,140,.4);background:linear-gradient(0deg,rgba(255,189,140,.06),rgba(255,189,140,.06)),var(--surface)} | |
| 569 | + | .banner.down{border-color:rgba(255,131,148,.45);background:linear-gradient(0deg,rgba(255,131,148,.07),rgba(255,131,148,.07)),var(--surface)} | |
| 570 | + | .banner.maintenance{border-color:rgba(142,197,255,.4);background:linear-gradient(0deg,rgba(142,197,255,.06),rgba(142,197,255,.06)),var(--surface)} | |
| 571 | + | .banner h1{margin:0;font:600 26px/1.2 var(--display);letter-spacing:-.02em} | |
| 572 | + | .banner p{margin:6px 0 0;color:var(--muted);font-size:14px} | |
| 573 | + | .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none} | |
| 574 | + | .dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px rgba(134,239,196,.12)} | |
| 575 | + | .dot.up{background:var(--up)}.dot.degraded,.dot.partial{background:var(--warn)}.dot.down{background:var(--down)}.dot.maintenance{background:var(--info)}.dot.unmonitored,.dot.unknown{background:var(--faint)} | |
| 576 | + | .dot.big.degraded{box-shadow:0 0 0 4px rgba(255,189,140,.14)}.dot.big.down{box-shadow:0 0 0 4px rgba(255,131,148,.16)}.dot.big.maintenance{box-shadow:0 0 0 4px rgba(142,197,255,.14)}.dot.big.unknown{box-shadow:none} | |
| 577 | + | .block{margin-top:40px} | |
| 578 | + | .block h2,.month h2{margin:0 0 12px;font:600 13px/1.4 var(--sans);color:var(--muted);letter-spacing:.02em} | |
| 579 | + | .block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px} | |
| 580 | + | .block-head a{font-size:13px;color:var(--muted);text-decoration:none}.block-head a:hover{color:var(--fg)} | |
| 581 | + | .components{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)} | |
| 582 | + | .component{padding:16px 18px 14px} | |
| 583 | + | .component+.component{border-top:1px solid var(--line)} | |
| 584 | + | .row{display:flex;align-items:baseline;justify-content:space-between;gap:12px} | |
| 585 | + | .name{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;min-width:0} | |
| 586 | + | .name h3{margin:0;font-size:15px;font-weight:600} | |
| 587 | + | .address{font:12px/1.4 var(--mono);color:var(--faint);overflow-wrap:anywhere} | |
| 588 | + | .state{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;white-space:nowrap} | |
| 589 | + | .s-up{color:var(--up)}.s-degraded,.s-partial{color:var(--warn)}.s-down{color:var(--down)}.s-maintenance{color:var(--info)}.s-unmonitored{color:var(--faint)} | |
| 590 | + | .seen{margin:2px 0 0;font-size:12px;color:var(--faint)} | |
| 591 | + | .unchecked{margin:8px 0 2px;font-size:13px;color:var(--faint)} | |
| 592 | + | .history{margin-top:12px} | |
| 593 | + | .bar{list-style:none;margin:0;padding:0;display:flex;gap:2px;height:30px} | |
| 594 | + | .bar li{flex:1 1 0;min-width:0;border-radius:2px;background:var(--line);transition:opacity .12s} | |
| 595 | + | .bar li.up{background:var(--up);opacity:.85}.bar li.degraded{background:var(--warn)}.bar li.down{background:var(--down)} | |
| 596 | + | .bar:hover li,.bar:focus li{opacity:.55}.bar li.on,.bar:hover li:hover{opacity:1;transform:scaleY(1.08)} | |
| 597 | + | .legend{display:flex;justify-content:space-between;margin-top:6px;font-size:12px;color:var(--faint)} | |
| 598 | + | .legend .uptime{color:var(--muted)} | |
| 599 | + | .legend .short{display:none} | |
| 600 | + | .detail{margin:4px 0 0;min-height:18px;font-size:12px;color:var(--soft)} | |
| 601 | + | .incident,.card{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:16px 18px;margin-bottom:12px} | |
| 602 | + | .incident.open.degraded{border-color:rgba(255,189,140,.4)}.incident.open.down{border-color:rgba(255,131,148,.45)}.incident.maint{border-color:rgba(142,197,255,.35)} | |
| 603 | + | .incident header,.page-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:6px 12px} | |
| 604 | + | .incident h3{margin:0;font-size:15px;font-weight:600} | |
| 605 | + | .incident h3 a,.line-head a{text-decoration:none}.incident h3 a:hover,.line-head a:hover{text-decoration:underline;text-underline-offset:3px} | |
| 606 | + | .page-head{margin-top:20px} | |
| 607 | + | .page-head h1{margin:0;font:600 24px/1.25 var(--display);letter-spacing:-.02em;overflow-wrap:anywhere} | |
| 608 | + | .crumbs{margin:24px 0 0;font-size:13px;color:var(--faint)}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--fg)} | |
| 609 | + | .pill{font-size:12px;font-weight:500;padding:1px 8px;border-radius:999px;border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap} | |
| 610 | + | .pill.degraded{color:var(--warn);border-color:rgba(255,189,140,.4)}.pill.down{color:var(--down);border-color:rgba(255,131,148,.45)}.pill.maint{color:var(--info);border-color:rgba(142,197,255,.4)} | |
| 611 | + | .meta{margin:4px 0 0;font-size:12px;color:var(--faint)} | |
| 612 | + | .impacts{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px} | |
| 613 | + | .affects{font-size:12px;color:var(--muted)} | |
| 614 | + | .impact{margin-left:4px;font-weight:500}.i-degraded,.i-partial_outage{color:var(--warn)}.i-major_outage{color:var(--down)}.i-operational{color:var(--up)} | |
| 615 | + | .updates{list-style:none;margin:12px 0 0;padding:0 0 0 14px;border-left:1px solid var(--line-strong)} | |
| 616 | + | .card>.updates{margin-top:0} | |
| 617 | + | .updates li+li{margin-top:12px} | |
| 618 | + | .when{margin:0;font-size:12px;color:var(--faint)}.when.latest{margin-top:8px}.when strong{color:var(--soft);font-weight:600;margin-right:4px} | |
| 619 | + | .text p,.text li{margin:4px 0 0;font-size:14px;color:var(--soft)} | |
| 620 | + | .text ul{margin:4px 0 0;padding-left:20px} | |
| 621 | + | .empty{margin:0;color:var(--muted);font-size:14px;padding:16px 18px;border:1px dashed var(--line-strong);border-radius:14px} | |
| 622 | + | .muted{color:var(--muted);font-size:14px} | |
| 623 | + | .note{margin:14px 0 0;font-size:14px;color:var(--soft);padding:10px 14px;border:1px solid rgba(182,168,255,.35);border-radius:10px;background:rgba(182,168,255,.06)} | |
| 624 | + | .lines{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)} | |
| 625 | + | .line{padding:12px 18px}.line+.line{border-top:1px solid var(--line)} | |
| 626 | + | .line-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-weight:600;font-size:15px} | |
| 627 | + | .tag{font-size:12px;font-weight:500;color:var(--lav);text-decoration:none;white-space:nowrap} | |
| 628 | + | .last{margin:4px 0 0;font-size:13px;color:var(--muted)} | |
| 629 | + | .month{margin-top:28px}.month:has(.none){margin-top:18px}.month:has(.none) h2{margin-bottom:2px} | |
| 630 | + | .postmortem .pm-section+.pm-section{margin-top:18px} | |
| 631 | + | .pm-section h3{margin:0;font-size:15px;font-weight:600} | |
| 632 | + | form.subscribe{display:flex;gap:8px;flex-wrap:wrap} | |
| 633 | + | input[type=email]{flex:1 1 220px;min-width:0;font:inherit;font-size:14px;color:var(--fg);background:var(--bg);border:1px solid var(--line-strong);border-radius:8px;padding:9px 12px} | |
| 634 | + | input[type=email]::placeholder{color:var(--faint)} | |
| 635 | + | button{font:inherit;font-size:14px;font-weight:600;color:var(--bg);background:var(--lav);border:0;border-radius:8px;padding:9px 16px;cursor:pointer} | |
| 636 | + | button:hover{background:#c8bdff} | |
| 637 | + | .trap{position:absolute;left:-9999px;width:1px;height:1px;opacity:0} | |
| 638 | + | .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} | |
| 639 | + | .feeds{margin:10px 0 0;font-size:13px;color:var(--faint)}.feeds a,.muted a,.note a{color:var(--soft)} | |
| 640 | + | .stack{display:flex;flex-direction:column;gap:10px}.stack label{font-size:13px;color:var(--muted)} | |
| 641 | + | .stack button{align-self:flex-start}.stack input[type=email]{flex:none} | |
| 642 | + | .page-head+.card,.page-head+.muted+.card{margin-top:16px} | |
| 643 | + | .month .none{margin:0;font-size:13px;color:var(--faint)} | |
| 644 | + | fieldset{border:0;padding:0;margin:6px 0 0}legend{font-size:13px;color:var(--muted);padding:0} | |
| 645 | + | fieldset .muted{margin:2px 0 8px;font-size:13px} | |
| 646 | + | .checks{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px} | |
| 647 | + | .check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--soft)!important;padding:6px 10px;border:1px solid var(--line);border-radius:8px} | |
| 648 | + | .check input{accent-color:var(--lav)} | |
| 649 | + | .message{margin-top:24px}.message h1{margin:0;font:600 22px/1.25 var(--display)}.message p{color:var(--muted);font-size:14px} | |
| 650 | + | .about p{margin:0;font-size:13px;color:var(--muted);max-width:68ch} | |
| 651 | + | .about dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0 0;font-size:13px} | |
| 652 | + | .about dt{color:var(--soft)}.about dd{margin:0;color:var(--faint)} | |
| 653 | + | .foot{display:flex;flex-wrap:wrap;gap:8px;margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--faint)} | |
| 654 | + | .foot a{text-decoration:none;color:var(--muted)}.foot a:hover{color:var(--fg)} | |
| 655 | + | @media (max-width:640px){ | |
| 656 | + | .bar li:nth-child(-n+${HISTORY_DAYS - 30}){display:none} | |
| 657 | + | .bar{gap:3px} | |
| 658 | + | .legend .long{display:none}.legend .short{display:inline} | |
| 659 | + | .banner{padding:16px}.banner h1{font-size:22px} | |
| 660 | + | .component,.incident,.card,.line{padding:14px} | |
| 661 | + | .about dl{grid-template-columns:1fr;gap:2px}.about dd{margin-bottom:8px} | |
| 662 | + | } | |
| 663 | + | @media (prefers-reduced-motion:reduce){.bar li{transition:none}} | |
| 664 | + | `; |
| 1 | + | /** | |
| 2 | + | * The status page's D1 database: each part's last check and daily tally | |
| 3 | + | * (migrations/0001), and incidents, maintenance, postmortems, subscribers, | |
| 4 | + | * detection and the audit log (migrations/0002). | |
| 5 | + | */ | |
| 6 | + | import type { | |
| 7 | + | AdminIncident, | |
| 8 | + | AdminIncidentDetail, | |
| 9 | + | AdminMaintenance, | |
| 10 | + | ComponentImpact, | |
| 11 | + | FollowUp, | |
| 12 | + | ImpactInput, | |
| 13 | + | IncidentSeverity, | |
| 14 | + | IncidentStatus, | |
| 15 | + | IncidentVisibility, | |
| 16 | + | MaintenanceState, | |
| 17 | + | Postmortem, | |
| 18 | + | PostmortemFields, | |
| 19 | + | StatusAuditEntry, | |
| 20 | + | StatusIncident, | |
| 21 | + | StatusMaintenance, | |
| 22 | + | TimelineEntry, | |
| 23 | + | TimelineKind, | |
| 24 | + | } from "@g1t/contracts"; | |
| 25 | + | ||
| 26 | + | import type { Streak } from "./detect.ts"; | |
| 27 | + | import { type Entry, type IncidentFacts, durations, shortId } from "./incidents.ts"; | |
| 28 | + | import { type Current, type DayRow, HISTORY_DAYS, dayOf, overallImpact } from "./model.ts"; | |
| 29 | + | import { postmortemDraft } from "./postmortem.ts"; | |
| 30 | + | import { parseParts, wants } from "./subscribers.ts"; | |
| 31 | + | ||
| 32 | + | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 33 | + | const iso = (ms: number) => new Date(ms).toISOString(); | |
| 34 | + | ||
| 35 | + | /** One part's check, ready to keep. */ | |
| 36 | + | export type Observation = Current & { component: string }; | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * Keeps one round of checks: each part's state, today's tally, and the | |
| 40 | + | * time. Parts under maintenance keep their last check but are left out of | |
| 41 | + | * the tally: failures in a planned window do not count against uptime. | |
| 42 | + | */ | |
| 43 | + | export async function record(db: D1Database, observations: Observation[], at: Date, maintenance: Set<string> = new Set()): Promise<void> { | |
| 44 | + | const when = at.toISOString(); | |
| 45 | + | const day = dayOf(at); | |
| 46 | + | const oldest = dayOf(at.getTime() - HISTORY_DAYS * DAY_MS); | |
| 47 | + | const statements: D1PreparedStatement[] = []; | |
| 48 | + | for (const o of observations) { | |
| 49 | + | statements.push( | |
| 50 | + | db | |
| 51 | + | .prepare( | |
| 52 | + | `INSERT INTO current (component, state, detail, latency_ms, checked_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 53 | + | ON CONFLICT (component) DO UPDATE SET state = ?2, detail = ?3, latency_ms = ?4, checked_at = ?5`, | |
| 54 | + | ) | |
| 55 | + | .bind(o.component, o.state, o.detail, o.latency_ms, when), | |
| 56 | + | ); | |
| 57 | + | if (o.state === "unmonitored" || maintenance.has(o.component)) continue; | |
| 58 | + | const up = o.state === "up" ? 1 : 0; | |
| 59 | + | const degraded = o.state === "degraded" ? 1 : 0; | |
| 60 | + | const down = o.state === "down" ? 1 : 0; | |
| 61 | + | // A latency only counts when it answered: a timeout is not a speed. | |
| 62 | + | const latency = o.state !== "down" && o.latency_ms != null ? o.latency_ms : 0; | |
| 63 | + | const counted = o.state !== "down" && o.latency_ms != null ? 1 : 0; | |
| 64 | + | statements.push( | |
| 65 | + | db | |
| 66 | + | .prepare( | |
| 67 | + | `INSERT INTO daily (component, day, checks, up, degraded, down, latency_total, latency_count) | |
| 68 | + | VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7) | |
| 69 | + | ON CONFLICT (component, day) DO UPDATE SET | |
| 70 | + | checks = checks + 1, up = up + ?3, degraded = degraded + ?4, down = down + ?5, | |
| 71 | + | latency_total = latency_total + ?6, latency_count = latency_count + ?7`, | |
| 72 | + | ) | |
| 73 | + | .bind(o.component, day, up, degraded, down, latency, counted), | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | statements.push( | |
| 77 | + | db.prepare(`INSERT INTO meta (key, value) VALUES ('checked_at', ?1) ON CONFLICT (key) DO UPDATE SET value = ?1`).bind(when), | |
| 78 | + | ); | |
| 79 | + | statements.push(db.prepare(`DELETE FROM daily WHERE day < ?1`).bind(oldest)); | |
| 80 | + | await db.batch(statements); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // --- Rows ------------------------------------------------------------------------ | |
| 84 | + | ||
| 85 | + | type IncidentRow = { | |
| 86 | + | id: string; | |
| 87 | + | title: string; | |
| 88 | + | severity: IncidentSeverity; | |
| 89 | + | status: IncidentStatus; | |
| 90 | + | visibility: IncidentVisibility; | |
| 91 | + | source: "declared" | "detected"; | |
| 92 | + | started_at: string; | |
| 93 | + | declared_at: string; | |
| 94 | + | acknowledged_at: string | null; | |
| 95 | + | mitigated_at: string | null; | |
| 96 | + | resolved_at: string | null; | |
| 97 | + | published_at: string | null; | |
| 98 | + | commander: string | null; | |
| 99 | + | communications: string | null; | |
| 100 | + | created_by: string; | |
| 101 | + | }; | |
| 102 | + | type ComponentRow = { incident_id: string; component: string; impact: ComponentImpact }; | |
| 103 | + | type TimelineRow = { | |
| 104 | + | id: string; | |
| 105 | + | incident_id: string; | |
| 106 | + | at: string; | |
| 107 | + | by: string; | |
| 108 | + | kind: TimelineKind; | |
| 109 | + | public: number; | |
| 110 | + | status: IncidentStatus | null; | |
| 111 | + | text: string; | |
| 112 | + | notified: number | null; | |
| 113 | + | }; | |
| 114 | + | type FollowUpRow = FollowUp & { incident_id: string }; | |
| 115 | + | type PostmortemRow = PostmortemFields & { incident_id: string; updated_at: string; updated_by: string; published_at: string | null }; | |
| 116 | + | type MaintenanceRow = { | |
| 117 | + | id: string; | |
| 118 | + | title: string; | |
| 119 | + | message: string; | |
| 120 | + | components: string; | |
| 121 | + | starts_at: string; | |
| 122 | + | ends_at: string; | |
| 123 | + | state: MaintenanceState; | |
| 124 | + | notify: number; | |
| 125 | + | created_at: string; | |
| 126 | + | created_by: string; | |
| 127 | + | }; | |
| 128 | + | type MaintenanceUpdateRow = { id: string; maintenance_id: string; at: string; by: string; text: string; notified: number | null }; | |
| 129 | + | ||
| 130 | + | function keys(json: string): string[] { | |
| 131 | + | try { | |
| 132 | + | const value = JSON.parse(json); | |
| 133 | + | return Array.isArray(value) ? value.map(String) : []; | |
| 134 | + | } catch { | |
| 135 | + | return []; | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const rows = <T>(result: D1Result | undefined) => (result?.results ?? []) as T[]; | |
| 140 | + | ||
| 141 | + | /** `in (?, ?, ?)` for a list, as bind arguments. */ | |
| 142 | + | function inList(ids: string[]): string { | |
| 143 | + | return ids.map(() => "?").join(", ") || "NULL"; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | export function incidentUrl(origin: string, id: string): string { | |
| 147 | + | return `${origin}/incidents/${id}`; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | export function maintenanceUrl(origin: string, id: string): string { | |
| 151 | + | return `${origin}/maintenance/${id}`; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | function components(id: string, all: ComponentRow[]): ImpactInput[] { | |
| 155 | + | return all.filter((c) => c.incident_id === id).map((c) => ({ key: c.component, impact: c.impact })); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | function toAdmin(row: IncidentRow, comps: ComponentRow[], timeline: TimelineRow[], followups: FollowUpRow[], pm: PostmortemRow | null): AdminIncident { | |
| 159 | + | const mine = followups.filter((f) => f.incident_id === row.id); | |
| 160 | + | const lastUpdate = timeline.filter((t) => t.incident_id === row.id && t.public === 1).reduce<string | null>((a, t) => (a && a > t.at ? a : t.at), null); | |
| 161 | + | return { | |
| 162 | + | id: row.id, | |
| 163 | + | title: row.title, | |
| 164 | + | severity: row.severity, | |
| 165 | + | status: row.status, | |
| 166 | + | visibility: row.visibility, | |
| 167 | + | source: row.source, | |
| 168 | + | components: components(row.id, comps), | |
| 169 | + | started_at: row.started_at, | |
| 170 | + | declared_at: row.declared_at, | |
| 171 | + | acknowledged_at: row.acknowledged_at, | |
| 172 | + | mitigated_at: row.mitigated_at, | |
| 173 | + | resolved_at: row.resolved_at, | |
| 174 | + | published_at: row.published_at, | |
| 175 | + | commander: row.commander, | |
| 176 | + | communications: row.communications, | |
| 177 | + | created_by: row.created_by, | |
| 178 | + | postmortem_published_at: pm?.published_at ?? null, | |
| 179 | + | durations: durations(row), | |
| 180 | + | followups_open: mine.filter((f) => !f.done_at).length, | |
| 181 | + | followups_done: mine.filter((f) => f.done_at).length, | |
| 182 | + | last_update_at: lastUpdate, | |
| 183 | + | }; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | function toPublic(row: IncidentRow, comps: ComponentRow[], timeline: TimelineRow[], origin: string, pmPublished: string | null): StatusIncident { | |
| 187 | + | const impacts = components(row.id, comps).filter((c) => c.impact !== "operational"); | |
| 188 | + | return { | |
| 189 | + | id: row.id, | |
| 190 | + | title: row.title, | |
| 191 | + | impact: overallImpact(impacts), | |
| 192 | + | status: row.status, | |
| 193 | + | components: impacts.map((c) => c.key), | |
| 194 | + | component_impacts: impacts, | |
| 195 | + | started_at: row.started_at, | |
| 196 | + | resolved_at: row.resolved_at, | |
| 197 | + | url: incidentUrl(origin, row.id), | |
| 198 | + | postmortem_published_at: pmPublished, | |
| 199 | + | updates: timeline | |
| 200 | + | .filter((t) => t.incident_id === row.id && t.public === 1) | |
| 201 | + | .sort((a, b) => b.at.localeCompare(a.at)) | |
| 202 | + | .map((t) => ({ id: t.id, at: t.at, status: t.status ?? row.status, text: t.text })), | |
| 203 | + | }; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | function toTimeline(t: TimelineRow): TimelineEntry { | |
| 207 | + | return { id: t.id, at: t.at, by: t.by, kind: t.kind, public: t.public === 1, status: t.status, text: t.text, notified: t.notified }; | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | function toMaintenance(row: MaintenanceRow, updates: MaintenanceUpdateRow[], origin: string): AdminMaintenance { | |
| 211 | + | return { | |
| 212 | + | id: row.id, | |
| 213 | + | title: row.title, | |
| 214 | + | message: row.message, | |
| 215 | + | components: keys(row.components), | |
| 216 | + | starts_at: row.starts_at, | |
| 217 | + | ends_at: row.ends_at, | |
| 218 | + | state: row.state, | |
| 219 | + | url: maintenanceUrl(origin, row.id), | |
| 220 | + | updates: updates | |
| 221 | + | .filter((u) => u.maintenance_id === row.id) | |
| 222 | + | .sort((a, b) => b.at.localeCompare(a.at)) | |
| 223 | + | .map((u) => ({ id: u.id, at: u.at, text: u.text })), | |
| 224 | + | created_at: row.created_at, | |
| 225 | + | created_by: row.created_by, | |
| 226 | + | }; | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /** The public view of maintenance: no staff emails. */ | |
| 230 | + | export function publicMaintenance(m: AdminMaintenance): StatusMaintenance { | |
| 231 | + | const { created_at: _a, created_by: _b, ...rest } = m; | |
| 232 | + | return rest; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // --- Public reads ------------------------------------------------------------------ | |
| 236 | + | ||
| 237 | + | /** Public incidents matching `where`, with their parts and public updates. */ | |
| 238 | + | async function publicIncidents(db: D1Database, where: string, args: unknown[], origin: string): Promise<StatusIncident[]> { | |
| 239 | + | const list = rows<IncidentRow>( | |
| 240 | + | (await db.prepare(`SELECT * FROM incident WHERE visibility = 'public' AND (${where}) ORDER BY started_at DESC LIMIT 500`).bind(...args).all()) as D1Result, | |
| 241 | + | ); | |
| 242 | + | if (!list.length) return []; | |
| 243 | + | const ids = list.map((i) => i.id); | |
| 244 | + | const [comps, timeline, pms] = await db.batch([ | |
| 245 | + | db.prepare(`SELECT * FROM incident_component WHERE incident_id IN (${inList(ids)})`).bind(...ids), | |
| 246 | + | db.prepare(`SELECT * FROM incident_timeline WHERE public = 1 AND incident_id IN (${inList(ids)})`).bind(...ids), | |
| 247 | + | db.prepare(`SELECT incident_id, published_at FROM postmortem WHERE published_at IS NOT NULL AND incident_id IN (${inList(ids)})`).bind(...ids), | |
| 248 | + | ]); | |
| 249 | + | const published = new Map(rows<{ incident_id: string; published_at: string }>(pms).map((p) => [p.incident_id, p.published_at])); | |
| 250 | + | return list.map((row) => toPublic(row, rows(comps), rows(timeline), origin, published.get(row.id) ?? null)); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | async function maintenanceWhere(db: D1Database, where: string, args: unknown[], origin: string): Promise<AdminMaintenance[]> { | |
| 254 | + | const list = rows<MaintenanceRow>((await db.prepare(`SELECT * FROM maintenance WHERE ${where} ORDER BY starts_at DESC LIMIT 500`).bind(...args).all()) as D1Result); | |
| 255 | + | if (!list.length) return []; | |
| 256 | + | const ids = list.map((m) => m.id); | |
| 257 | + | const updates = rows<MaintenanceUpdateRow>( | |
| 258 | + | (await db.prepare(`SELECT * FROM maintenance_update WHERE maintenance_id IN (${inList(ids)})`).bind(...ids).all()) as D1Result, | |
| 259 | + | ); | |
| 260 | + | return list.map((m) => toMaintenance(m, updates, origin)); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /** Everything the front page reads. */ | |
| 264 | + | export async function load(db: D1Database, now: Date, origin: string) { | |
| 265 | + | const since = dayOf(now.getTime() - HISTORY_DAYS * DAY_MS); | |
| 266 | + | const [current, meta, days] = await db.batch([ | |
| 267 | + | db.prepare(`SELECT component, state, detail, latency_ms FROM current`), | |
| 268 | + | db.prepare(`SELECT value FROM meta WHERE key = 'checked_at'`), | |
| 269 | + | db.prepare(`SELECT * FROM daily WHERE day >= ?1`).bind(since), | |
| 270 | + | ]); | |
| 271 | + | const [incidents, maintenance] = await Promise.all([ | |
| 272 | + | publicIncidents(db, `resolved_at IS NULL OR resolved_at >= ?1`, [iso(now.getTime() - HISTORY_DAYS * DAY_MS)], origin), | |
| 273 | + | maintenanceWhere(db, `state IN ('scheduled', 'in_progress')`, [], origin), | |
| 274 | + | ]); | |
| 275 | + | const currentRows = rows<Current & { component: string }>(current); | |
| 276 | + | return { | |
| 277 | + | current: new Map(currentRows.map((row) => [row.component, { state: row.state, detail: row.detail, latency_ms: row.latency_ms }])), | |
| 278 | + | checkedAt: (rows<{ value?: string }>(meta)[0]?.value ?? null) as string | null, | |
| 279 | + | days: rows<DayRow>(days), | |
| 280 | + | incidents, | |
| 281 | + | maintenance: maintenance.map(publicMaintenance), | |
| 282 | + | }; | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /** One public incident, with its postmortem once published. */ | |
| 286 | + | export async function loadPublicIncident(db: D1Database, id: string, origin: string) { | |
| 287 | + | const [incident] = await publicIncidents(db, `id = ?1`, [id], origin); | |
| 288 | + | if (!incident) return null; | |
| 289 | + | const pm = (await db.prepare(`SELECT * FROM postmortem WHERE incident_id = ?1 AND published_at IS NOT NULL`).bind(id).first()) as PostmortemRow | null; | |
| 290 | + | return { incident, postmortem: pm }; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | export async function loadPublicMaintenance(db: D1Database, id: string, origin: string): Promise<StatusMaintenance | null> { | |
| 294 | + | const [m] = await maintenanceWhere(db, `id = ?1`, [id], origin); | |
| 295 | + | return m ? publicMaintenance(m) : null; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /** Incidents and finished maintenance since `since`, for the history page and the feeds. */ | |
| 299 | + | export async function loadHistory(db: D1Database, since: Date, origin: string) { | |
| 300 | + | const at = since.toISOString(); | |
| 301 | + | const [incidents, maintenance] = await Promise.all([ | |
| 302 | + | publicIncidents(db, `started_at >= ?1 OR resolved_at IS NULL`, [at], origin), | |
| 303 | + | maintenanceWhere(db, `starts_at >= ?1 OR state IN ('scheduled', 'in_progress')`, [at], origin), | |
| 304 | + | ]); | |
| 305 | + | return { incidents, maintenance: maintenance.map(publicMaintenance) }; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // --- Staff reads ------------------------------------------------------------------- | |
| 309 | + | ||
| 310 | + | async function adminIncidents(db: D1Database, where: string, args: unknown[]): Promise<{ list: IncidentRow[]; comps: ComponentRow[]; timeline: TimelineRow[]; followups: FollowUpRow[]; pms: PostmortemRow[] }> { | |
| 311 | + | const list = rows<IncidentRow>((await db.prepare(`SELECT * FROM incident WHERE ${where} ORDER BY started_at DESC LIMIT 500`).bind(...args).all()) as D1Result); | |
| 312 | + | if (!list.length) return { list, comps: [], timeline: [], followups: [], pms: [] }; | |
| 313 | + | const ids = list.map((i) => i.id); | |
| 314 | + | const [comps, timeline, followups, pms] = await db.batch([ | |
| 315 | + | db.prepare(`SELECT * FROM incident_component WHERE incident_id IN (${inList(ids)})`).bind(...ids), | |
| 316 | + | db.prepare(`SELECT * FROM incident_timeline WHERE incident_id IN (${inList(ids)}) ORDER BY at, rowid`).bind(...ids), | |
| 317 | + | db.prepare(`SELECT * FROM incident_followup WHERE incident_id IN (${inList(ids)}) ORDER BY created_at, rowid`).bind(...ids), | |
| 318 | + | db.prepare(`SELECT * FROM postmortem WHERE incident_id IN (${inList(ids)})`).bind(...ids), | |
| 319 | + | ]); | |
| 320 | + | return { list, comps: rows(comps), timeline: rows(timeline), followups: rows(followups), pms: rows(pms) }; | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /** Sudo's board: open and draft incidents, the last 180 days, maintenance, and subscribers. */ | |
| 324 | + | export async function board(db: D1Database, now: Date, origin: string) { | |
| 325 | + | const since = iso(now.getTime() - 180 * DAY_MS); | |
| 326 | + | const [data, maintenance, count] = await Promise.all([ | |
| 327 | + | adminIncidents(db, `resolved_at IS NULL OR resolved_at >= ?1`, [since]), | |
| 328 | + | maintenanceWhere(db, `state IN ('scheduled', 'in_progress') OR ends_at >= ?1`, [iso(now.getTime() - HISTORY_DAYS * DAY_MS)], origin), | |
| 329 | + | db.prepare(`SELECT COUNT(*) AS n FROM subscriber WHERE confirmed_at IS NOT NULL`).first<{ n: number }>(), | |
| 330 | + | ]); | |
| 331 | + | return { | |
| 332 | + | incidents: data.list.map((row) => toAdmin(row, data.comps, data.timeline, data.followups, data.pms.find((p) => p.incident_id === row.id) ?? null)), | |
| 333 | + | maintenance, | |
| 334 | + | subscribers: count?.n ?? 0, | |
| 335 | + | }; | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | export async function openCount(db: D1Database): Promise<number> { | |
| 339 | + | const row = await db.prepare(`SELECT COUNT(*) AS n FROM incident WHERE resolved_at IS NULL AND visibility IN ('draft', 'public')`).first<{ n: number }>(); | |
| 340 | + | return row?.n ?? 0; | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | export async function incidentDetail(db: D1Database, id: string, origin: string, names: Map<string, string>): Promise<AdminIncidentDetail | null> { | |
| 344 | + | const data = await adminIncidents(db, `id = ?1`, [id]); | |
| 345 | + | const row = data.list[0]; | |
| 346 | + | if (!row) return null; | |
| 347 | + | const pmRow = data.pms[0] ?? null; | |
| 348 | + | const incident = toAdmin(row, data.comps, data.timeline, data.followups, pmRow); | |
| 349 | + | const timeline = data.timeline.map(toTimeline); | |
| 350 | + | const followups: FollowUp[] = data.followups.map(({ incident_id: _, ...f }) => ({ | |
| 351 | + | id: f.id, | |
| 352 | + | title: f.title, | |
| 353 | + | owner: f.owner, | |
| 354 | + | done_at: f.done_at, | |
| 355 | + | created_at: f.created_at, | |
| 356 | + | created_by: f.created_by, | |
| 357 | + | })); | |
| 358 | + | const postmortem: Postmortem | null = pmRow | |
| 359 | + | ? { | |
| 360 | + | summary: pmRow.summary, | |
| 361 | + | impact: pmRow.impact, | |
| 362 | + | timeline: pmRow.timeline, | |
| 363 | + | root_cause: pmRow.root_cause, | |
| 364 | + | went_well: pmRow.went_well, | |
| 365 | + | went_badly: pmRow.went_badly, | |
| 366 | + | action_items: pmRow.action_items, | |
| 367 | + | updated_at: pmRow.updated_at, | |
| 368 | + | updated_by: pmRow.updated_by, | |
| 369 | + | published_at: pmRow.published_at, | |
| 370 | + | } | |
| 371 | + | : null; | |
| 372 | + | return { | |
| 373 | + | ...incident, | |
| 374 | + | timeline, | |
| 375 | + | followups, | |
| 376 | + | postmortem, | |
| 377 | + | postmortem_draft: postmortemDraft(incident, timeline, followups, names), | |
| 378 | + | url: incidentUrl(origin, id), | |
| 379 | + | }; | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | export function facts(i: AdminIncident): IncidentFacts { | |
| 383 | + | return { | |
| 384 | + | status: i.status, | |
| 385 | + | severity: i.severity, | |
| 386 | + | visibility: i.visibility, | |
| 387 | + | components: i.components, | |
| 388 | + | started_at: i.started_at, | |
| 389 | + | acknowledged_at: i.acknowledged_at, | |
| 390 | + | mitigated_at: i.mitigated_at, | |
| 391 | + | resolved_at: i.resolved_at, | |
| 392 | + | commander: i.commander, | |
| 393 | + | communications: i.communications, | |
| 394 | + | }; | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | export async function maintenanceById(db: D1Database, id: string, origin: string): Promise<AdminMaintenance | null> { | |
| 398 | + | return (await maintenanceWhere(db, `id = ?1`, [id], origin))[0] ?? null; | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // --- Writes ------------------------------------------------------------------------ | |
| 402 | + | ||
| 403 | + | export function auditStatement(db: D1Database, at: string, by: string, action: string, target: string, detail: string): D1PreparedStatement { | |
| 404 | + | return db | |
| 405 | + | .prepare(`INSERT INTO audit (id, at, by, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`) | |
| 406 | + | .bind(crypto.randomUUID(), at, by, action, target, detail.slice(0, 500)); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /** Timeline lines, written a millisecond apart so their order survives. */ | |
| 410 | + | function timelineStatements(db: D1Database, incident: string, entries: (Entry & { notified?: number | null })[], at: Date, by: string): D1PreparedStatement[] { | |
| 411 | + | return entries.map((e, i) => | |
| 412 | + | db | |
| 413 | + | .prepare(`INSERT INTO incident_timeline (id, incident_id, at, by, kind, public, status, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`) | |
| 414 | + | .bind(shortId(), incident, iso(at.getTime() + i), by, e.kind, e.public ? 1 : 0, e.status, e.text, e.notified ?? null), | |
| 415 | + | ); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | function componentStatements(db: D1Database, incident: string, list: ImpactInput[]): D1PreparedStatement[] { | |
| 419 | + | return list.map((c) => | |
| 420 | + | db | |
| 421 | + | .prepare(`INSERT INTO incident_component (incident_id, component, impact) VALUES (?1, ?2, ?3) ON CONFLICT (incident_id, component) DO UPDATE SET impact = ?3`) | |
| 422 | + | .bind(incident, c.key, c.impact), | |
| 423 | + | ); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | export type NewIncidentRow = { | |
| 427 | + | title: string; | |
| 428 | + | severity: IncidentSeverity; | |
| 429 | + | status: IncidentStatus; | |
| 430 | + | visibility: IncidentVisibility; | |
| 431 | + | source: "declared" | "detected"; | |
| 432 | + | components: ImpactInput[]; | |
| 433 | + | started_at: string; | |
| 434 | + | acknowledged_at: string | null; | |
| 435 | + | commander: string | null; | |
| 436 | + | communications: string | null; | |
| 437 | + | by: string; | |
| 438 | + | }; | |
| 439 | + | ||
| 440 | + | /** A new incident with its first lines. Returns its id. */ | |
| 441 | + | export async function createIncident( | |
| 442 | + | db: D1Database, | |
| 443 | + | input: NewIncidentRow, | |
| 444 | + | entries: (Entry & { notified?: number | null })[], | |
| 445 | + | now: Date, | |
| 446 | + | audit: { action: string; detail: string } | null, | |
| 447 | + | id = shortId(), | |
| 448 | + | ): Promise<string> { | |
| 449 | + | const at = now.toISOString(); | |
| 450 | + | await db.batch([ | |
| 451 | + | db | |
| 452 | + | .prepare( | |
| 453 | + | `INSERT INTO incident (id, title, severity, status, visibility, source, started_at, declared_at, acknowledged_at, published_at, commander, communications, created_by) | |
| 454 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`, | |
| 455 | + | ) | |
| 456 | + | .bind( | |
| 457 | + | id, | |
| 458 | + | input.title, | |
| 459 | + | input.severity, | |
| 460 | + | input.status, | |
| 461 | + | input.visibility, | |
| 462 | + | input.source, | |
| 463 | + | input.started_at, | |
| 464 | + | at, | |
| 465 | + | input.acknowledged_at, | |
| 466 | + | input.visibility === "public" ? at : null, | |
| 467 | + | input.commander, | |
| 468 | + | input.communications, | |
| 469 | + | input.by, | |
| 470 | + | ), | |
| 471 | + | ...componentStatements(db, id, input.components), | |
| 472 | + | ...timelineStatements(db, id, entries, now, input.by), | |
| 473 | + | ...(audit ? [auditStatement(db, at, input.by, audit.action, id, audit.detail)] : []), | |
| 474 | + | ]); | |
| 475 | + | return id; | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | /** Saves an incident's changed facts and the lines that say so. */ | |
| 479 | + | export async function saveIncident( | |
| 480 | + | db: D1Database, | |
| 481 | + | id: string, | |
| 482 | + | next: IncidentFacts & { title?: string; published_at?: string | null }, | |
| 483 | + | entries: (Entry & { notified?: number | null })[], | |
| 484 | + | now: Date, | |
| 485 | + | by: string, | |
| 486 | + | audit: { action: string; detail: string }, | |
| 487 | + | ): Promise<void> { | |
| 488 | + | await db.batch([ | |
| 489 | + | db | |
| 490 | + | .prepare( | |
| 491 | + | `UPDATE incident SET status = ?2, severity = ?3, visibility = ?4, acknowledged_at = ?5, mitigated_at = ?6, resolved_at = ?7, | |
| 492 | + | commander = ?8, communications = ?9, title = COALESCE(?10, title), published_at = COALESCE(published_at, ?11) WHERE id = ?1`, | |
| 493 | + | ) | |
| 494 | + | .bind( | |
| 495 | + | id, | |
| 496 | + | next.status, | |
| 497 | + | next.severity, | |
| 498 | + | next.visibility, | |
| 499 | + | next.acknowledged_at, | |
| 500 | + | next.mitigated_at, | |
| 501 | + | next.resolved_at, | |
| 502 | + | next.commander, | |
| 503 | + | next.communications, | |
| 504 | + | next.title ?? null, | |
| 505 | + | next.published_at ?? null, | |
| 506 | + | ), | |
| 507 | + | ...componentStatements(db, id, next.components), | |
| 508 | + | ...timelineStatements(db, id, entries, now, by), | |
| 509 | + | auditStatement(db, now.toISOString(), by, audit.action, id, audit.detail), | |
| 510 | + | ]); | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | /** Lines the checks write on open incidents: failing again, or answering again. */ | |
| 514 | + | export async function addSystemLines(db: D1Database, lines: { incident: string; kind: TimelineKind; text: string }[], now: Date): Promise<void> { | |
| 515 | + | if (!lines.length) return; | |
| 516 | + | await db.batch(lines.flatMap((l) => timelineStatements(db, l.incident, [{ kind: l.kind, public: false, status: null, text: l.text }], now, "status"))); | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | export async function addFollowUp(db: D1Database, incident: string, input: { title: string; owner: string | null; by: string }, now: Date): Promise<FollowUp> { | |
| 520 | + | const f: FollowUp = { id: shortId(), title: input.title, owner: input.owner, done_at: null, created_at: now.toISOString(), created_by: input.by }; | |
| 521 | + | await db.batch([ | |
| 522 | + | db | |
| 523 | + | .prepare(`INSERT INTO incident_followup (id, incident_id, title, owner, created_at, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`) | |
| 524 | + | .bind(f.id, incident, f.title, f.owner, f.created_at, f.created_by), | |
| 525 | + | ...timelineStatements(db, incident, [{ kind: "followup", public: false, status: null, text: `Follow-up: ${f.title}${f.owner ? ` (${f.owner})` : ""}.` }], now, input.by), | |
| 526 | + | auditStatement(db, f.created_at, input.by, "incident_followup", incident, `Added follow-up: ${f.title}`), | |
| 527 | + | ]); | |
| 528 | + | return f; | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | export async function setFollowUp(db: D1Database, incident: string, id: string, done: boolean, by: string, now: Date): Promise<FollowUp | null> { | |
| 532 | + | const row = (await db.prepare(`SELECT * FROM incident_followup WHERE id = ?1 AND incident_id = ?2`).bind(id, incident).first()) as FollowUpRow | null; | |
| 533 | + | if (!row) return null; | |
| 534 | + | const at = now.toISOString(); | |
| 535 | + | await db.batch([ | |
| 536 | + | db.prepare(`UPDATE incident_followup SET done_at = ?2, done_by = ?3 WHERE id = ?1`).bind(id, done ? at : null, done ? by : null), | |
| 537 | + | ...timelineStatements(db, incident, [{ kind: "followup", public: false, status: null, text: `${done ? "Done" : "Reopened"}: ${row.title}.` }], now, by), | |
| 538 | + | auditStatement(db, at, by, "incident_followup", incident, `${done ? "Done" : "Reopened"}: ${row.title}`), | |
| 539 | + | ]); | |
| 540 | + | return { id, title: row.title, owner: row.owner, done_at: done ? at : null, created_at: row.created_at, created_by: row.created_by }; | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | export async function savePostmortem(db: D1Database, incident: string, fields: PostmortemFields, by: string, now: Date): Promise<void> { | |
| 544 | + | const at = now.toISOString(); | |
| 545 | + | await db.batch([ | |
| 546 | + | db | |
| 547 | + | .prepare( | |
| 548 | + | `INSERT INTO postmortem (incident_id, summary, impact, timeline, root_cause, went_well, went_badly, action_items, updated_at, updated_by) | |
| 549 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) | |
| 550 | + | ON CONFLICT (incident_id) DO UPDATE SET summary = ?2, impact = ?3, timeline = ?4, root_cause = ?5, went_well = ?6, went_badly = ?7, | |
| 551 | + | action_items = ?8, updated_at = ?9, updated_by = ?10`, | |
| 552 | + | ) | |
| 553 | + | .bind(incident, fields.summary, fields.impact, fields.timeline, fields.root_cause, fields.went_well, fields.went_badly, fields.action_items, at, by), | |
| 554 | + | auditStatement(db, at, by, "postmortem_saved", incident, "Saved the postmortem draft"), | |
| 555 | + | ]); | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | export async function publishPostmortem(db: D1Database, incident: string, publish: boolean, by: string, now: Date): Promise<void> { | |
| 559 | + | const at = now.toISOString(); | |
| 560 | + | await db.batch([ | |
| 561 | + | db.prepare(`UPDATE postmortem SET published_at = ?2, published_by = ?3 WHERE incident_id = ?1`).bind(incident, publish ? at : null, publish ? by : null), | |
| 562 | + | ...timelineStatements(db, incident, [{ kind: "postmortem", public: false, status: null, text: publish ? "Postmortem published." : "Postmortem taken down." }], now, by), | |
| 563 | + | auditStatement(db, at, by, publish ? "postmortem_published" : "postmortem_unpublished", incident, publish ? "Published the postmortem" : "Took the postmortem down"), | |
| 564 | + | ]); | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // --- Maintenance --------------------------------------------------------------------- | |
| 568 | + | ||
| 569 | + | export async function scheduleMaintenance( | |
| 570 | + | db: D1Database, | |
| 571 | + | input: { title: string; message: string; components: string[]; starts_at: string; ends_at: string; notify: boolean; by: string }, | |
| 572 | + | notified: number | null, | |
| 573 | + | now: Date, | |
| 574 | + | id = shortId(), | |
| 575 | + | ): Promise<string> { | |
| 576 | + | const at = now.toISOString(); | |
| 577 | + | await db.batch([ | |
| 578 | + | db | |
| 579 | + | .prepare( | |
| 580 | + | `INSERT INTO maintenance (id, title, message, components, starts_at, ends_at, state, notify, created_at, created_by) | |
| 581 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled', ?7, ?8, ?9)`, | |
| 582 | + | ) | |
| 583 | + | .bind(id, input.title, input.message, JSON.stringify(input.components), input.starts_at, input.ends_at, input.notify ? 1 : 0, at, input.by), | |
| 584 | + | db | |
| 585 | + | .prepare(`INSERT INTO maintenance_update (id, maintenance_id, at, by, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`) | |
| 586 | + | .bind(shortId(), id, at, input.by, input.message, notified), | |
| 587 | + | auditStatement(db, at, input.by, "maintenance_scheduled", id, `${input.title} (${input.starts_at} to ${input.ends_at})`), | |
| 588 | + | ]); | |
| 589 | + | return id; | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | /** Moves maintenance along and posts its update. Null `state` keeps it where it is. */ | |
| 593 | + | export async function maintenanceUpdate( | |
| 594 | + | db: D1Database, | |
| 595 | + | id: string, | |
| 596 | + | state: MaintenanceState | null, | |
| 597 | + | text: string, | |
| 598 | + | by: string, | |
| 599 | + | notified: number | null, | |
| 600 | + | now: Date, | |
| 601 | + | audit: { action: string; detail: string } | null, | |
| 602 | + | ): Promise<void> { | |
| 603 | + | const at = now.toISOString(); | |
| 604 | + | await db.batch([ | |
| 605 | + | ...(state ? [db.prepare(`UPDATE maintenance SET state = ?2 WHERE id = ?1`).bind(id, state)] : []), | |
| 606 | + | db | |
| 607 | + | .prepare(`INSERT INTO maintenance_update (id, maintenance_id, at, by, text, notified) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`) | |
| 608 | + | .bind(shortId(), id, at, by, text, notified), | |
| 609 | + | ...(audit ? [auditStatement(db, at, by, audit.action, id, audit.detail)] : []), | |
| 610 | + | ]); | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | /** Maintenance whose window has started or ended but whose state has not caught up. */ | |
| 614 | + | export async function dueMaintenance(db: D1Database, now: Date, origin: string): Promise<(AdminMaintenance & { notify: boolean })[]> { | |
| 615 | + | const at = now.toISOString(); | |
| 616 | + | const list = rows<MaintenanceRow>( | |
| 617 | + | (await db | |
| 618 | + | .prepare(`SELECT * FROM maintenance WHERE (state = 'scheduled' AND starts_at <= ?1) OR (state = 'in_progress' AND ends_at <= ?1)`) | |
| 619 | + | .bind(at) | |
| 620 | + | .all()) as D1Result, | |
| 621 | + | ); | |
| 622 | + | return list.map((row) => ({ ...toMaintenance(row, [], origin), notify: row.notify === 1 })); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | // --- Subscribers ----------------------------------------------------------------------- | |
| 626 | + | ||
| 627 | + | type SubscriberRow = { | |
| 628 | + | id: string; | |
| 629 | + | email: string; | |
| 630 | + | components: string | null; | |
| 631 | + | pending_components: string | null; | |
| 632 | + | confirm_hash: string | null; | |
| 633 | + | confirm_expires_at: string | null; | |
| 634 | + | confirm_sent_at: string | null; | |
| 635 | + | confirmed_at: string | null; | |
| 636 | + | }; | |
| 637 | + | ||
| 638 | + | /** | |
| 639 | + | * Asks for a subscription (or a change of parts): a new confirmation, | |
| 640 | + | * unless one went out to this address in the last `resendAfterMs`. The | |
| 641 | + | * caller answers the same either way, so the form never tells whether an | |
| 642 | + | * address is subscribed. | |
| 643 | + | */ | |
| 644 | + | export async function requestSubscription( | |
| 645 | + | db: D1Database, | |
| 646 | + | email: string, | |
| 647 | + | parts: string[] | null, | |
| 648 | + | hash: string, | |
| 649 | + | now: Date, | |
| 650 | + | ttlMs: number, | |
| 651 | + | resendAfterMs: number, | |
| 652 | + | ): Promise<{ send: boolean }> { | |
| 653 | + | const at = now.toISOString(); | |
| 654 | + | const existing = (await db.prepare(`SELECT * FROM subscriber WHERE email = ?1`).bind(email).first()) as SubscriberRow | null; | |
| 655 | + | if (existing?.confirm_sent_at && now.getTime() - Date.parse(existing.confirm_sent_at) < resendAfterMs) return { send: false }; | |
| 656 | + | const pending = parts ? JSON.stringify(parts) : null; | |
| 657 | + | if (existing) { | |
| 658 | + | await db | |
| 659 | + | .prepare(`UPDATE subscriber SET pending_components = ?2, confirm_hash = ?3, confirm_expires_at = ?4, confirm_sent_at = ?5 WHERE id = ?1`) | |
| 660 | + | .bind(existing.id, pending, hash, iso(now.getTime() + ttlMs), at) | |
| 661 | + | .run(); | |
| 662 | + | } else { | |
| 663 | + | await db | |
| 664 | + | .prepare( | |
| 665 | + | `INSERT INTO subscriber (id, email, components, pending_components, confirm_hash, confirm_expires_at, confirm_sent_at, created_at) | |
| 666 | + | VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, ?6)`, | |
| 667 | + | ) | |
| 668 | + | .bind(crypto.randomUUID(), email, pending, hash, iso(now.getTime() + ttlMs), at) | |
| 669 | + | .run(); | |
| 670 | + | } | |
| 671 | + | return { send: true }; | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | /** Confirms the subscription a token's hash belongs to; null when none or expired. */ | |
| 675 | + | export async function confirmSubscription(db: D1Database, hash: string, now: Date): Promise<{ email: string; parts: string[] | null } | null> { | |
| 676 | + | const row = (await db.prepare(`SELECT * FROM subscriber WHERE confirm_hash = ?1`).bind(hash).first()) as SubscriberRow | null; | |
| 677 | + | if (!row || !row.confirm_expires_at || Date.parse(row.confirm_expires_at) < now.getTime()) return null; | |
| 678 | + | await db | |
| 679 | + | .prepare( | |
| 680 | + | `UPDATE subscriber SET components = pending_components, pending_components = NULL, confirm_hash = NULL, confirm_expires_at = NULL, | |
| 681 | + | confirmed_at = COALESCE(confirmed_at, ?2) WHERE id = ?1`, | |
| 682 | + | ) | |
| 683 | + | .bind(row.id, now.toISOString()) | |
| 684 | + | .run(); | |
| 685 | + | return { email: row.email, parts: parseParts(row.pending_components) }; | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | export async function unsubscribe(db: D1Database, id: string): Promise<boolean> { | |
| 689 | + | const result = await db.prepare(`DELETE FROM subscriber WHERE id = ?1`).bind(id).run(); | |
| 690 | + | return (result.meta?.changes ?? 0) > 0; | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | /** Confirmed subscribers who want news about these parts. */ | |
| 694 | + | export async function recipients(db: D1Database, about: string[]): Promise<{ id: string; email: string }[]> { | |
| 695 | + | const all = rows<SubscriberRow>((await db.prepare(`SELECT id, email, components FROM subscriber WHERE confirmed_at IS NOT NULL`).all()) as D1Result); | |
| 696 | + | return all.filter((s) => wants(parseParts(s.components), about)).map((s) => ({ id: s.id, email: s.email })); | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // --- Detection --------------------------------------------------------------------------- | |
| 700 | + | ||
| 701 | + | export async function loadStreaks(db: D1Database): Promise<Map<string, Streak>> { | |
| 702 | + | const list = rows<{ component: string; state: "degraded" | "down"; count: number; since: string; alerted: number }>( | |
| 703 | + | (await db.prepare(`SELECT * FROM streak`).all()) as D1Result, | |
| 704 | + | ); | |
| 705 | + | return new Map(list.map((s) => [s.component, { ...s, alerted: s.alerted === 1 }])); | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | export async function saveStreaks(db: D1Database, streaks: Streak[]): Promise<void> { | |
| 709 | + | const keep = streaks.map((s) => s.component); | |
| 710 | + | await db.batch([ | |
| 711 | + | db.prepare(`DELETE FROM streak WHERE component NOT IN (${inList(keep)})`).bind(...keep), | |
| 712 | + | ...streaks.map((s) => | |
| 713 | + | db | |
| 714 | + | .prepare( | |
| 715 | + | `INSERT INTO streak (component, state, count, since, alerted) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 716 | + | ON CONFLICT (component) DO UPDATE SET state = ?2, count = ?3, since = ?4, alerted = ?5`, | |
| 717 | + | ) | |
| 718 | + | .bind(s.component, s.state, s.count, s.since, s.alerted ? 1 : 0), | |
| 719 | + | ), | |
| 720 | + | ]); | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | /** Open incidents, drafts included, with the parts they affect: what detection checks against. */ | |
| 724 | + | export async function openRefs(db: D1Database): Promise<{ id: string; components: string[] }[]> { | |
| 725 | + | const list = rows<{ id: string; component: string | null }>( | |
| 726 | + | (await db | |
| 727 | + | .prepare( | |
| 728 | + | `SELECT i.id, c.component FROM incident i LEFT JOIN incident_component c ON c.incident_id = i.id AND c.impact != 'operational' | |
| 729 | + | WHERE i.resolved_at IS NULL AND i.visibility IN ('draft', 'public')`, | |
| 730 | + | ) | |
| 731 | + | .all()) as D1Result, | |
| 732 | + | ); | |
| 733 | + | const map = new Map<string, string[]>(); | |
| 734 | + | for (const r of list) { | |
| 735 | + | const parts = map.get(r.id) ?? []; | |
| 736 | + | if (r.component) parts.push(r.component); | |
| 737 | + | map.set(r.id, parts); | |
| 738 | + | } | |
| 739 | + | return [...map].map(([id, components]) => ({ id, components })); | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | // --- Audit --------------------------------------------------------------------------------- | |
| 743 | + | ||
| 744 | + | export async function auditLog(db: D1Database, before: string | null, limit = 100): Promise<StatusAuditEntry[]> { | |
| 745 | + | const statement = before | |
| 746 | + | ? db.prepare(`SELECT * FROM audit WHERE at < ?1 ORDER BY at DESC LIMIT ?2`).bind(before, limit) | |
| 747 | + | : db.prepare(`SELECT * FROM audit ORDER BY at DESC LIMIT ?1`).bind(limit); | |
| 748 | + | return rows<StatusAuditEntry>((await statement.all()) as D1Result); | |
| 749 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { alertLetter, bindingSender, confirmLetter, memorySender, render, unsubscribeHeaders, updateLetter } from "./email.ts"; | |
| 5 | + | import { atom, feedItems, jsonFeed } from "./feed.ts"; | |
| 6 | + | import { chosenParts, hashToken, newToken, normalizeEmail, parseParts, readUnsubscribeToken, unsubscribeToken, wants } from "./subscribers.ts"; | |
| 7 | + | ||
| 8 | + | test("addresses are trimmed, lowercased and checked", () => { | |
| 9 | + | assert.equal(normalizeEmail(" Ana@Example.COM "), "ana@example.com"); | |
| 10 | + | assert.equal(normalizeEmail("nope"), null); | |
| 11 | + | assert.equal(normalizeEmail("a@b"), null); | |
| 12 | + | assert.equal(normalizeEmail("a b@example.com"), null); | |
| 13 | + | assert.equal(normalizeEmail(42), null); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("confirmation tokens: random, and only their hash is kept", async () => { | |
| 17 | + | const a = newToken(); | |
| 18 | + | assert.match(a, /^[A-Za-z0-9_-]{43}$/); | |
| 19 | + | assert.notEqual(newToken(), a); | |
| 20 | + | const hash = await hashToken(a); | |
| 21 | + | assert.match(hash, /^[0-9a-f]{64}$/); | |
| 22 | + | assert.equal(await hashToken(a), hash, "the same token hashes the same"); | |
| 23 | + | assert.ok(!hash.includes(a)); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("unsubscribe links are signed, not stored, and refuse tampering", async () => { | |
| 27 | + | const token = await unsubscribeToken("secret", "sub-1"); | |
| 28 | + | assert.ok(token.startsWith("sub-1.")); | |
| 29 | + | assert.equal(await readUnsubscribeToken("secret", token), "sub-1"); | |
| 30 | + | assert.equal(await readUnsubscribeToken("other", token), null, "another secret"); | |
| 31 | + | assert.equal(await readUnsubscribeToken("secret", token.replace("sub-1", "sub-2")), null, "another subscriber"); | |
| 32 | + | assert.equal(await readUnsubscribeToken("secret", `${token}x`), null); | |
| 33 | + | assert.equal(await readUnsubscribeToken("secret", "garbage"), null); | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | test("per-part subscriptions", () => { | |
| 37 | + | assert.equal(chosenParts([], ["git", "api"]), null, "none chosen is everything"); | |
| 38 | + | assert.equal(chosenParts(["git", "api"], ["git", "api"]), null, "all chosen is everything"); | |
| 39 | + | assert.deepEqual(chosenParts(["git", "git", "zzz"], ["git", "api"]), ["git"]); | |
| 40 | + | assert.equal(parseParts(null), null); | |
| 41 | + | assert.deepEqual(parseParts('["git"]'), ["git"]); | |
| 42 | + | assert.equal(parseParts("not json"), null); | |
| 43 | + | assert.equal(wants(null, ["api"]), true); | |
| 44 | + | assert.equal(wants(["git"], ["api"]), false); | |
| 45 | + | assert.equal(wants(["git"], ["api", "git"]), true); | |
| 46 | + | assert.equal(wants(["git"], []), true, "news about no part in particular goes to everyone"); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("letters: escaped HTML, plain text, and an unsubscribe link in every update", () => { | |
| 50 | + | const { text, html } = render(updateLetter({ heading: "Identified: <Pushes>", text: "A bad deploy.", url: "https://status.g1t.sh/incidents/a", affects: ["Git"], unsubscribe: "https://status.g1t.sh/unsubscribe?token=x" })); | |
| 51 | + | assert.ok(html.includes("Identified: <Pushes>") && !html.includes("<Pushes>")); | |
| 52 | + | assert.ok(text.includes("Unsubscribe: https://status.g1t.sh/unsubscribe?token=x")); | |
| 53 | + | assert.ok(text.includes("Affects: Git.")); | |
| 54 | + | assert.equal(unsubscribeHeaders("https://u")["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); | |
| 55 | + | assert.ok(render(confirmLetter("https://c", ["Git"])).text.includes("affecting Git")); | |
| 56 | + | assert.ok(render(alertLetter({ title: "Detected: API not answering", parts: ["API"], since: "5 Oct 12:00 UTC", link: "https://sudo.g1t.sh/incidents/x" })).text.includes("https://sudo.g1t.sh/incidents/x")); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("senders: the binding when there is one, none otherwise", async () => { | |
| 60 | + | assert.equal(bindingSender(undefined), null); | |
| 61 | + | const calls: unknown[] = []; | |
| 62 | + | const sender = bindingSender({ send: async (m) => void calls.push(m) }, "Status <s@g1t.sh>"); | |
| 63 | + | await sender!.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" }); | |
| 64 | + | assert.deepEqual(calls, [{ to: "a@b.co", from: "Status <s@g1t.sh>", subject: "Hi", text: "t", html: "h" }]); | |
| 65 | + | const memory = memorySender(); | |
| 66 | + | await memory.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" }); | |
| 67 | + | assert.equal(memory.sent.length, 1); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("feeds: every public update and maintenance notice, newest first, in Atom and JSON Feed", () => { | |
| 71 | + | const incidents = [ | |
| 72 | + | { | |
| 73 | + | id: "a", | |
| 74 | + | title: "Pushes & pulls failing", | |
| 75 | + | impact: "down" as const, | |
| 76 | + | status: "resolved" as const, | |
| 77 | + | components: ["git"], | |
| 78 | + | component_impacts: [{ key: "git", impact: "major_outage" as const }], | |
| 79 | + | started_at: "2026-10-05T11:00:00Z", | |
| 80 | + | resolved_at: "2026-10-05T12:00:00Z", | |
| 81 | + | url: "https://status.g1t.sh/incidents/a", | |
| 82 | + | postmortem_published_at: null, | |
| 83 | + | updates: [ | |
| 84 | + | { id: "u2", at: "2026-10-05T12:00:00Z", status: "resolved" as const, text: "Fixed." }, | |
| 85 | + | { id: "u1", at: "2026-10-05T11:05:00Z", status: "investigating" as const, text: "Looking <now>." }, | |
| 86 | + | ], | |
| 87 | + | }, | |
| 88 | + | ]; | |
| 89 | + | const maintenance = [ | |
| 90 | + | { id: "m", title: "Upgrade", message: "m", components: ["git"], starts_at: "2026-10-06T02:00:00Z", ends_at: "2026-10-06T03:00:00Z", state: "scheduled" as const, url: "https://status.g1t.sh/maintenance/m", updates: [{ id: "mu", at: "2026-10-05T11:30:00Z", text: "Scheduled." }] }, | |
| 91 | + | ]; | |
| 92 | + | const items = feedItems(incidents, maintenance); | |
| 93 | + | assert.deepEqual(items.map((i) => i.title), ["Resolved: Pushes & pulls failing", "Maintenance: Upgrade", "Investigating: Pushes & pulls failing"]); | |
| 94 | + | assert.equal(items[0]!.url, "https://status.g1t.sh/incidents/a#update-u2"); | |
| 95 | + | const xml = atom(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "2026-10-05T13:00:00Z" }); | |
| 96 | + | assert.ok(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom">')); | |
| 97 | + | assert.ok(xml.includes("<title>Resolved: Pushes & pulls failing</title>")); | |
| 98 | + | assert.ok(xml.includes("Looking <now>.")); | |
| 99 | + | assert.ok(xml.includes("<updated>2026-10-05T12:00:00Z</updated>")); | |
| 100 | + | assert.equal((xml.match(/<entry>/g) ?? []).length, 3); | |
| 101 | + | const json = jsonFeed(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "" }) as { version: string; items: { id: string; content_text: string }[] }; | |
| 102 | + | assert.equal(json.version, "https://jsonfeed.org/version/1.1"); | |
| 103 | + | assert.equal(json.items[2]!.content_text, "Looking <now>."); | |
| 104 | + | assert.equal(feedItems(incidents, maintenance, 1).length, 1); | |
| 105 | + | }); |
| 1 | + | /** | |
| 2 | + | * Email subscribers: double opt-in and one-click leaving. Only hashes are | |
| 3 | + | * kept. A confirmation token is random and stored as its SHA-256; an | |
| 4 | + | * unsubscribe link is the subscriber's id signed with STATUS_SECRET, so it | |
| 5 | + | * is never stored at all and a copy of the database cannot unsubscribe | |
| 6 | + | * anyone. Web Crypto only, so it is tested under Node. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | /** How long a confirmation link works. */ | |
| 10 | + | export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000; | |
| 11 | + | /** No second confirmation email to one address sooner than this. */ | |
| 12 | + | export const RESEND_AFTER_MS = 10 * 60 * 1000; | |
| 13 | + | ||
| 14 | + | const EMAIL = /^[^\s@<>"(),;:]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,}$/; | |
| 15 | + | ||
| 16 | + | /** An address as kept: trimmed and lowercased; null when it is not one. */ | |
| 17 | + | export function normalizeEmail(raw: unknown): string | null { | |
| 18 | + | const email = typeof raw === "string" ? raw.trim().toLowerCase() : ""; | |
| 19 | + | return email.length <= 254 && EMAIL.test(email) ? email : null; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | function base64url(bytes: Uint8Array): string { | |
| 23 | + | let text = ""; | |
| 24 | + | for (const b of bytes) text += String.fromCharCode(b); | |
| 25 | + | return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** A new random token, as it goes in a link. */ | |
| 29 | + | export function newToken(): string { | |
| 30 | + | return base64url(crypto.getRandomValues(new Uint8Array(32))); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** What is kept of a token: its SHA-256, hex. */ | |
| 34 | + | export async function hashToken(token: string): Promise<string> { | |
| 35 | + | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); | |
| 36 | + | return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | async function hmac(secret: string, message: string): Promise<string> { | |
| 40 | + | const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 41 | + | return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)))); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** The token in a subscriber's unsubscribe link: `<id>.<signature>`. */ | |
| 45 | + | export async function unsubscribeToken(secret: string, id: string): Promise<string> { | |
| 46 | + | return `${id}.${await hmac(secret, `unsubscribe:${id}`)}`; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** The subscriber an unsubscribe token is for, or null when it is not genuine. */ | |
| 50 | + | export async function readUnsubscribeToken(secret: string, token: string): Promise<string | null> { | |
| 51 | + | const dot = token.lastIndexOf("."); | |
| 52 | + | if (dot <= 0) return null; | |
| 53 | + | const id = token.slice(0, dot); | |
| 54 | + | const expected = await unsubscribeToken(secret, id); | |
| 55 | + | if (expected.length !== token.length) return null; | |
| 56 | + | let diff = 0; | |
| 57 | + | for (let i = 0; i < token.length; i++) diff |= expected.charCodeAt(i) ^ token.charCodeAt(i); | |
| 58 | + | return diff === 0 ? id : null; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A subscriber's parts: null for everything. */ | |
| 62 | + | export function parseParts(json: string | null): string[] | null { | |
| 63 | + | if (!json) return null; | |
| 64 | + | try { | |
| 65 | + | const value = JSON.parse(json); | |
| 66 | + | return Array.isArray(value) && value.length ? value.map(String) : null; | |
| 67 | + | } catch { | |
| 68 | + | return null; | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /** Whether a subscriber wants news about these parts. */ | |
| 73 | + | export function wants(parts: string[] | null, about: string[]): boolean { | |
| 74 | + | return parts == null || about.length === 0 || about.some((key) => parts.includes(key)); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /** The parts chosen in the form, known ones only; null (everything) when none or all are. */ | |
| 78 | + | export function chosenParts(raw: string[], known: string[]): string[] | null { | |
| 79 | + | const parts = [...new Set(raw.filter((key) => known.includes(key)))]; | |
| 80 | + | return parts.length === 0 || parts.length === known.length ? null : parts; | |
| 81 | + | } |
| 1 | + | { | |
| 2 | + | "extends": "../../tsconfig.base.json", | |
| 3 | + | "compilerOptions": { | |
| 4 | + | // Node runs the tests on these files as they are, which needs the | |
| 5 | + | // extension on relative imports. | |
| 6 | + | "allowImportingTsExtensions": true | |
| 7 | + | }, | |
| 8 | + | "include": [ | |
| 9 | + | "src/**/*", | |
| 10 | + | "worker-configuration.d.ts" | |
| 11 | + | ], | |
| 12 | + | "exclude": [ | |
| 13 | + | "src/**/*.test.ts" | |
| 14 | + | ] | |
| 15 | + | } |
| 1 | + | { | |
| 2 | + | "$schema": "../../node_modules/wrangler/config-schema.json", | |
| 3 | + | "name": "g1t-status", | |
| 4 | + | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | + | "compatibility_date": "2026-09-26", | |
| 6 | + | "main": "./src/index.ts", | |
| 7 | + | "workers_dev": false, | |
| 8 | + | "preview_urls": false, | |
| 9 | + | // The status page. A Worker of its own, bound to nothing it reports on | |
| 10 | + | // but billing, so it stays up when the site does not. A custom domain, | |
| 11 | + | // whose DNS record Cloudflare makes on the first deploy. | |
| 12 | + | "routes": [{ "pattern": "status.g1t.sh", "custom_domain": true }], | |
| 13 | + | // Every minute: check each part and keep the result. | |
| 14 | + | "triggers": { "crons": ["* * * * *"] }, | |
| 15 | + | // The fonts, from packages/theme, served from the page's own origin. | |
| 16 | + | "rules": [{ "type": "Data", "globs": ["**/*.woff2"], "fallthrough": false }], | |
| 17 | + | // Each part's last check, 90 days of daily tallies, and incidents. | |
| 18 | + | // Made by `npx wrangler d1 create g1t-status`; put its id here. | |
| 19 | + | "d1_databases": [ | |
| 20 | + | { | |
| 21 | + | "binding": "DB", | |
| 22 | + | "database_name": "g1t-status", | |
| 23 | + | "database_id": "dba85721-96dc-401d-9121-a6c587d614b2", | |
| 24 | + | "migrations_dir": "migrations" | |
| 25 | + | } | |
| 26 | + | ], | |
| 27 | + | // Billing has no public address: its price book is read through this. | |
| 28 | + | // Nothing else is bound, so every other check goes over the internet. | |
| 29 | + | "services": [{ "binding": "BILLING", "service": "g1t-billing" }], | |
| 30 | + | // Email to subscribers (confirmations, incident and maintenance | |
| 31 | + | // updates) and the staff alert for detected drafts, through Cloudflare | |
| 32 | + | // Email Sending, as identity sends its own. Subscriptions also need the | |
| 33 | + | // STATUS_SECRET secret, which signs unsubscribe links: | |
| 34 | + | // npx wrangler secret put STATUS_SECRET | |
| 35 | + | // Without either, the page offers the feeds only. | |
| 36 | + | "send_email": [{ "name": "EMAIL", "remote": true }], | |
| 37 | + | "vars": { | |
| 38 | + | // Where each part is. An empty one leaves that part off the page, | |
| 39 | + | // for an installation that does not run it. | |
| 40 | + | "SITE_URL": "https://g1t.sh", | |
| 41 | + | "API_URL": "https://api.g1t.sh", | |
| 42 | + | "MCP_URL": "https://mcp.g1t.sh", | |
| 43 | + | "DOCS_URL": "https://docs.g1t.sh", | |
| 44 | + | "PAGES_URL": "https://g1t.page", | |
| 45 | + | "MODELS_URL": "https://models.g1t.sh", | |
| 46 | + | // A public repository that always exists, whose branches are listed | |
| 47 | + | // as a clone would. | |
| 48 | + | "PROBE_REPO": "flagon-io/g1t", | |
| 49 | + | "SUPPORT_URL": "https://g1t.sh/support", | |
| 50 | + | // This page's own address, for links in email and the feeds. | |
| 51 | + | "STATUS_URL": "https://status.g1t.sh", | |
| 52 | + | // sudo, for the link in the staff alert. | |
| 53 | + | "SUDO_URL": "https://sudo.g1t.sh", | |
| 54 | + | // Who hears when a part fails three checks in a row and a draft | |
| 55 | + | // incident is made. Empty sends none. | |
| 56 | + | "STATUS_ALERT_EMAIL": "hey@flagon.io", | |
| 57 | + | "STATUS_FROM": "g1t status <noreply@g1t.sh>", | |
| 58 | + | "OG_IMAGE": "https://og.g1t.sh/image?path=%2Fstatus&v=2" | |
| 59 | + | }, | |
| 60 | + | "observability": { "enabled": true } | |
| 61 | + | } |
| 1 | + | /** | |
| 2 | + | * The Incidents pages' small parts: chips, a page's back link, the | |
| 3 | + | * timers strip and the component impact picker. Forms only: sudo ships | |
| 4 | + | * no JavaScript. | |
| 5 | + | */ | |
| 6 | + | import { ArrowLeft } from "lucide-react"; | |
| 7 | + | import type { ReactNode } from "react"; | |
| 8 | + | import { Link } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { AdminIncident, ComponentImpact, IncidentSeverity, MaintenanceState } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { Badge, Select } from "~/components/ui"; | |
| 13 | + | import { | |
| 14 | + | COMPONENT_IMPACTS, | |
| 15 | + | IMPACT_TONE, | |
| 16 | + | MAINTENANCE_TONE, | |
| 17 | + | MAINTENANCE_WORD, | |
| 18 | + | SEVERITY_TONE, | |
| 19 | + | impactLabel, | |
| 20 | + | phase, | |
| 21 | + | severityLabel, | |
| 22 | + | } from "~/lib/incidents"; | |
| 23 | + | ||
| 24 | + | export function SeverityBadge({ severity }: { severity: IncidentSeverity }) { | |
| 25 | + | return <Badge tone={SEVERITY_TONE[severity]}>{severityLabel(severity)}</Badge>; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export function PhaseBadge({ incident }: { incident: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at"> }) { | |
| 29 | + | const { label, tone } = phase(incident); | |
| 30 | + | return <Badge tone={tone}>{label}</Badge>; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export function ImpactBadge({ impact }: { impact: ComponentImpact }) { | |
| 34 | + | return <Badge tone={IMPACT_TONE[impact]}>{impactLabel(impact)}</Badge>; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function MaintenanceBadge({ state }: { state: MaintenanceState }) { | |
| 38 | + | return <Badge tone={MAINTENANCE_TONE[state]}>{MAINTENANCE_WORD[state]}</Badge>; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export function BackLink({ to, children }: { to: string; children: ReactNode }) { | |
| 42 | + | return ( | |
| 43 | + | <Link to={to} className="inline-flex items-center gap-1.5 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 44 | + | <ArrowLeft size={13} aria-hidden="true" /> | |
| 45 | + | {children} | |
| 46 | + | </Link> | |
| 47 | + | ); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** One timer: a label, a figure, and a line under it. */ | |
| 51 | + | export function Timer({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) { | |
| 52 | + | const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-accent" : "text-fg"; | |
| 53 | + | return ( | |
| 54 | + | <div className="min-w-0 px-4 py-3"> | |
| 55 | + | <p className="text-xs text-muted">{label}</p> | |
| 56 | + | <p className={`tabular mt-0.5 text-base font-semibold tracking-tight ${color}`}>{value}</p> | |
| 57 | + | {hint && <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>} | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** | |
| 63 | + | * A row per part with a select of impacts. `blank` adds a first choice | |
| 64 | + | * that leaves the part as it is (for updates); without it, every part | |
| 65 | + | * starts operational (for declaring). | |
| 66 | + | */ | |
| 67 | + | export function ImpactPicker({ | |
| 68 | + | components, | |
| 69 | + | current, | |
| 70 | + | blank, | |
| 71 | + | }: { | |
| 72 | + | components: { key: string; name: string }[]; | |
| 73 | + | current: Map<string, ComponentImpact>; | |
| 74 | + | blank?: boolean; | |
| 75 | + | }) { | |
| 76 | + | return ( | |
| 77 | + | <div className="divide-y divide-line rounded-md border border-line"> | |
| 78 | + | {components.map((c) => { | |
| 79 | + | const now = current.get(c.key) ?? "operational"; | |
| 80 | + | return ( | |
| 81 | + | <label key={c.key} className="flex items-center gap-3 px-3 py-1.5"> | |
| 82 | + | <span className="min-w-0 grow text-sm"> | |
| 83 | + | <span className="block truncate">{c.name}</span> | |
| 84 | + | {blank && now !== "operational" && <span className="block text-xs text-faint">now {impactLabel(now).toLowerCase()}</span>} | |
| 85 | + | </span> | |
| 86 | + | <span className="block w-40 shrink-0 sm:w-48"> | |
| 87 | + | <Select name={`impact.${c.key}`} defaultValue={blank ? "" : now} aria-label={`${c.name}: impact`} className="py-1 text-xs"> | |
| 88 | + | {blank && <option value="">No change</option>} | |
| 89 | + | {COMPONENT_IMPACTS.map((i) => ( | |
| 90 | + | <option key={i.value} value={i.value}> | |
| 91 | + | {i.label} | |
| 92 | + | </option> | |
| 93 | + | ))} | |
| 94 | + | </Select> | |
| 95 | + | </span> | |
| 96 | + | </label> | |
| 97 | + | ); | |
| 98 | + | })} | |
| 99 | + | </div> | |
| 100 | + | ); | |
| 101 | + | } |
| 30 | 30 | Server, | |
| 31 | 31 | ShieldAlert, | |
| 32 | 32 | ShieldCheck, | |
| 33 | + | Siren, | |
| 33 | 34 | Tags, | |
| 34 | 35 | Ticket, | |
| 35 | 36 | TrendingUp, | |
| 42 | 43 | import { Link, useLocation } from "react-router"; | |
| 43 | 44 | ||
| 44 | 45 | import { Logo } from "~/components/logo"; | |
| 45 | − | import { NAV, type NavGroup, type NavIcon, type NavItem, holdsCurrent, isCurrent } from "~/lib/nav"; | |
| 46 | + | import { NAV, type NavCounts, type NavGroup, type NavIcon, type NavItem, countFor, holdsCurrent, isCurrent } from "~/lib/nav"; | |
| 46 | 47 | ||
| 47 | 48 | const ICONS: Record<NavIcon, LucideIcon> = { | |
| 48 | 49 | overview: LayoutDashboard, | |
| 58 | 59 | costs: Cloud, | |
| 59 | 60 | agents: Bot, | |
| 60 | 61 | abuse: ShieldAlert, | |
| 62 | + | incidents: Siren, | |
| 61 | 63 | inbox: Inbox, | |
| 62 | 64 | "view-as": Eye, | |
| 63 | 65 | announcements: Megaphone, | |
| 81 | 83 | return <Icon size={size} className={className} />; | |
| 82 | 84 | } | |
| 83 | 85 | ||
| 86 | + | /** How many are waiting on a page, such as access requests; nothing at zero. */ | |
| 87 | + | function CountPill({ count, label }: { count: number; label: string }) { | |
| 88 | + | if (count <= 0) return null; | |
| 89 | + | return ( | |
| 90 | + | <span | |
| 91 | + | title={label} | |
| 92 | + | className="shrink-0 rounded-full bg-merged/15 px-1.5 py-px text-[0.6875rem] font-semibold text-merged tabular-nums ring-1 ring-merged/30" | |
| 93 | + | > | |
| 94 | + | {count > 99 ? "99+" : count} | |
| 95 | + | <span className="sr-only"> {label}</span> | |
| 96 | + | </span> | |
| 97 | + | ); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | const COUNT_LABEL = "waiting"; | |
| 101 | + | ||
| 102 | + | /** What an item's number counts, for screen readers: incidents are open, not waiting. */ | |
| 103 | + | function countLabel(item: NavItem): string { | |
| 104 | + | return item.count === "incidents" ? "open" : COUNT_LABEL; | |
| 105 | + | } | |
| 106 | + | ||
| 84 | 107 | function SoonPill() { | |
| 85 | 108 | return ( | |
| 86 | 109 | <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 90 | 113 | } | |
| 91 | 114 | ||
| 92 | 115 | /** A top-level link, with its icon. */ | |
| 93 | − | function TopLink({ item, pathname }: { item: NavItem; pathname: string }) { | |
| 116 | + | function TopLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) { | |
| 94 | 117 | const current = isCurrent(item, pathname); | |
| 95 | 118 | return ( | |
| 96 | 119 | <Link | |
| 103 | 126 | > | |
| 104 | 127 | <NavGlyph icon={item.icon} className={`shrink-0 ${current ? "text-merged" : "text-faint group-hover:text-muted"}`} /> | |
| 105 | 128 | <span className="min-w-0 grow truncate">{item.label}</span> | |
| 129 | + | <CountPill count={countFor([item], counts)} label={countLabel(item)} /> | |
| 106 | 130 | {item.soon && <SoonPill />} | |
| 107 | 131 | </Link> | |
| 108 | 132 | ); | |
| 109 | 133 | } | |
| 110 | 134 | ||
| 111 | 135 | /** A page inside a section: indented, no icon, the current one marked in lavender. */ | |
| 112 | − | function SubLink({ item, pathname }: { item: NavItem; pathname: string }) { | |
| 136 | + | function SubLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) { | |
| 113 | 137 | const current = isCurrent(item, pathname); | |
| 114 | 138 | return ( | |
| 115 | 139 | <Link | |
| 125 | 149 | }`} | |
| 126 | 150 | > | |
| 127 | 151 | <span className="min-w-0 grow truncate">{item.label}</span> | |
| 152 | + | <CountPill count={countFor([item], counts)} label={countLabel(item)} /> | |
| 128 | 153 | {item.soon && <SoonPill />} | |
| 129 | 154 | </Link> | |
| 130 | 155 | ); | |
| 131 | 156 | } | |
| 132 | 157 | ||
| 133 | 158 | /** A section: one row that folds open to its pages. Open when it holds the current page. */ | |
| 134 | − | function Section({ group, pathname }: { group: NavGroup & { title: string }; pathname: string }) { | |
| 159 | + | function Section({ group, pathname, counts }: { group: NavGroup & { title: string }; pathname: string; counts: NavCounts }) { | |
| 135 | 160 | const current = holdsCurrent(group, pathname); | |
| 161 | + | const waiting = countFor(group.items, counts); | |
| 136 | 162 | const allSoon = group.items.every((item) => item.soon); | |
| 137 | 163 | return ( | |
| 138 | 164 | <details open={current} className="group/section"> | |
| 148 | 174 | <SoonPill /> | |
| 149 | 175 | </span> | |
| 150 | 176 | )} | |
| 177 | + | {/* Folded, the section carries its pages' counts; open, the pages do. */} | |
| 178 | + | {waiting > 0 && ( | |
| 179 | + | <span className="group-open/section:hidden"> | |
| 180 | + | <CountPill count={waiting} label={COUNT_LABEL} /> | |
| 181 | + | </span> | |
| 182 | + | )} | |
| 151 | 183 | <ChevronRight size={14} aria-hidden="true" className="shrink-0 text-faint transition-transform duration-150 group-open/section:rotate-90" /> | |
| 152 | 184 | </summary> | |
| 153 | 185 | <ul className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line"> | |
| 154 | 186 | {group.items.map((item) => ( | |
| 155 | 187 | <li key={item.to}> | |
| 156 | − | <SubLink item={item} pathname={pathname} /> | |
| 188 | + | <SubLink item={item} pathname={pathname} counts={counts} /> | |
| 157 | 189 | </li> | |
| 158 | 190 | ))} | |
| 159 | 191 | </ul> | |
| 161 | 193 | ); | |
| 162 | 194 | } | |
| 163 | 195 | ||
| 164 | − | function NavTree({ pathname }: { pathname: string }) { | |
| 196 | + | function NavTree({ pathname, counts }: { pathname: string; counts: NavCounts }) { | |
| 165 | 197 | return ( | |
| 166 | 198 | <div className="space-y-px"> | |
| 167 | 199 | {NAV.map((group, index) => | |
| 168 | 200 | group.title ? ( | |
| 169 | − | <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} /> | |
| 201 | + | <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} counts={counts} /> | |
| 170 | 202 | ) : ( | |
| 171 | 203 | <ul key={index} className={`space-y-px ${index === 0 ? "" : "pt-2"} ${index < NAV.length - 1 ? "pb-2" : ""}`}> | |
| 172 | 204 | {group.items.map((item) => ( | |
| 173 | 205 | <li key={item.to}> | |
| 174 | − | <TopLink item={item} pathname={pathname} /> | |
| 206 | + | <TopLink item={item} pathname={pathname} counts={counts} /> | |
| 175 | 207 | </li> | |
| 176 | 208 | ))} | |
| 177 | 209 | </ul> | |
| 197 | 229 | } | |
| 198 | 230 | ||
| 199 | 231 | /** The sidebar, from `lg` up. */ | |
| 200 | − | export function Sidebar({ email }: { email: string | null | undefined }) { | |
| 232 | + | export function Sidebar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) { | |
| 201 | 233 | const { pathname } = useLocation(); | |
| 202 | 234 | return ( | |
| 203 | 235 | <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex"> | |
| 207 | 239 | </Link> | |
| 208 | 240 | </div> | |
| 209 | 241 | <nav aria-label="sudo" className="grow overflow-y-auto px-2 pt-2 pb-6"> | |
| 210 | − | <NavTree pathname={pathname} /> | |
| 242 | + | <NavTree pathname={pathname} counts={counts} /> | |
| 211 | 243 | </nav> | |
| 212 | 244 | <div className="shrink-0"> | |
| 213 | 245 | <SignedIn email={email} /> | |
| 217 | 249 | } | |
| 218 | 250 | ||
| 219 | 251 | /** The top bar and its menu, below `lg`. */ | |
| 220 | − | export function MobileBar({ email }: { email: string | null | undefined }) { | |
| 252 | + | export function MobileBar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) { | |
| 221 | 253 | const { pathname } = useLocation(); | |
| 222 | 254 | return ( | |
| 223 | 255 | <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden"> | |
| 238 | 270 | </summary> | |
| 239 | 271 | <div className="absolute inset-x-0 top-14 max-h-[calc(100dvh-3.5rem)] overflow-y-auto border-b border-line bg-bg shadow-2xl shadow-black/50"> | |
| 240 | 272 | <nav aria-label="sudo" className="px-2 pt-3 pb-4"> | |
| 241 | − | <NavTree pathname={pathname} /> | |
| 273 | + | <NavTree pathname={pathname} counts={counts} /> | |
| 242 | 274 | </nav> | |
| 243 | 275 | <SignedIn email={email} /> | |
| 244 | 276 | </div> |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { mergeAudit, accountPath, accountName, statusAuditActions } from "./ledgers.ts"; | |
| 5 | + | import { | |
| 6 | + | duration, | |
| 7 | + | filterIncidents, | |
| 8 | + | incidentsHref, | |
| 9 | + | maintenanceList, | |
| 10 | + | median, | |
| 11 | + | nextStatus, | |
| 12 | + | parseFilters, | |
| 13 | + | phase, | |
| 14 | + | readImpacts, | |
| 15 | + | staffSuggestions, | |
| 16 | + | tabCounts, | |
| 17 | + | tabOf, | |
| 18 | + | utc, | |
| 19 | + | wantsNotify, | |
| 20 | + | } from "./incidents.ts"; | |
| 21 | + | import { proseBlocks } from "./postmortem.ts"; | |
| 22 | + | ||
| 23 | + | const incident = (over: Record<string, unknown> = {}) => | |
| 24 | + | ({ | |
| 25 | + | title: "Pushes failing", | |
| 26 | + | severity: "sev2", | |
| 27 | + | visibility: "public", | |
| 28 | + | resolved_at: null, | |
| 29 | + | components: [{ key: "git", impact: "major_outage" }], | |
| 30 | + | status: "investigating", | |
| 31 | + | postmortem_published_at: null, | |
| 32 | + | ...over, | |
| 33 | + | }) as Parameters<typeof tabOf>[0] & Parameters<typeof phase>[0] & { title: string; severity: "sev2"; components: { key: string; impact: "major_outage" }[] }; | |
| 34 | + | ||
| 35 | + | test("filters come from the address; unknown values are dropped", () => { | |
| 36 | + | assert.deepEqual(parseFilters(new URLSearchParams("tab=drafts&severity=sev1&component=git&q=%20push%20")), { tab: "drafts", severity: "sev1", component: "git", q: "push" }); | |
| 37 | + | assert.deepEqual(parseFilters(new URLSearchParams("tab=nope&severity=sev9&component=%3Cx%3E")), { tab: "open", severity: null, component: null, q: "" }); | |
| 38 | + | assert.equal(incidentsHref({ tab: "open" }), "/incidents"); | |
| 39 | + | assert.equal(incidentsHref({ tab: "resolved", severity: "sev1", q: "a b" }), "/incidents?tab=resolved&severity=sev1&q=a+b"); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | test("tabs: open, drafts, and resolved (dismissed drafts included)", () => { | |
| 43 | + | assert.equal(tabOf(incident()), "open"); | |
| 44 | + | assert.equal(tabOf(incident({ visibility: "draft" })), "drafts"); | |
| 45 | + | assert.equal(tabOf(incident({ resolved_at: "2026-10-05T00:00:00Z" })), "resolved"); | |
| 46 | + | assert.equal(tabOf(incident({ visibility: "dismissed", resolved_at: "2026-10-05T00:00:00Z" })), "resolved"); | |
| 47 | + | const list = [incident(), incident({ title: "Docs slow", severity: "sev3", components: [{ key: "docs", impact: "degraded" }] }), incident({ visibility: "draft" })]; | |
| 48 | + | const all = { tab: "open" as const, severity: null, component: null, q: "" }; | |
| 49 | + | assert.equal(filterIncidents(list, all).length, 2); | |
| 50 | + | assert.equal(filterIncidents(list, { ...all, severity: "sev3" }).length, 1); | |
| 51 | + | assert.equal(filterIncidents(list, { ...all, component: "git" }).length, 1); | |
| 52 | + | assert.equal(filterIncidents(list, { ...all, q: "DOCS" })[0]!.title, "Docs slow"); | |
| 53 | + | assert.deepEqual(tabCounts(list, [{ state: "scheduled" }, { state: "completed" }]), { open: 2, maintenance: 1, resolved: 0, drafts: 1 }); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("maintenance: live first by start, then finished newest first", () => { | |
| 57 | + | const m = (id: string, state: string, starts_at: string) => ({ id, state, starts_at, components: ["git"] }) as never; | |
| 58 | + | assert.deepEqual( | |
| 59 | + | maintenanceList([m("done-old", "completed", "2026-09-01"), m("later", "scheduled", "2026-10-09"), m("done-new", "cancelled", "2026-10-01"), m("soon", "in_progress", "2026-10-05")], null).map((x: { id: string }) => x.id), | |
| 60 | + | ["soon", "later", "done-new", "done-old"], | |
| 61 | + | ); | |
| 62 | + | }); | |
| 63 | + | ||
| 64 | + | test("phase chips: draft, dismissed, postmortem, else the status", () => { | |
| 65 | + | assert.deepEqual(phase(incident({ visibility: "draft" })), { label: "Draft", tone: "lavender" }); | |
| 66 | + | assert.equal(phase(incident({ visibility: "dismissed" })).label, "Dismissed"); | |
| 67 | + | assert.equal(phase(incident({ postmortem_published_at: "x", status: "resolved" })).label, "Postmortem published"); | |
| 68 | + | assert.deepEqual(phase(incident({ status: "monitoring" })), { label: "Monitoring", tone: "info" }); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("durations, medians and UTC times", () => { | |
| 72 | + | assert.equal(duration(null), "—"); | |
| 73 | + | assert.equal(duration(59.6), "1m"); | |
| 74 | + | assert.equal(duration(3 * 3600 + 5 * 60), "3h 05m"); | |
| 75 | + | assert.equal(median([300, null, 100, 200]), 200); | |
| 76 | + | assert.equal(median([100, 200]), 150); | |
| 77 | + | assert.equal(median([null]), null); | |
| 78 | + | assert.equal(utc("2026-10-05T14:00"), "2026-10-05T14:00:00.000Z"); | |
| 79 | + | assert.equal(utc(""), null); | |
| 80 | + | assert.equal(utc("garbage"), null); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | test("forms: impacts per part, notify by severity, next status, staff suggestions", () => { | |
| 84 | + | const form = new FormData(); | |
| 85 | + | form.set("impact.git", "partial_outage"); | |
| 86 | + | form.set("impact.api", ""); | |
| 87 | + | form.set("impact.docs", "bogus"); | |
| 88 | + | assert.deepEqual(readImpacts(form, ["git", "api", "docs"]), [{ key: "git", impact: "partial_outage" }]); | |
| 89 | + | assert.equal(wantsNotify("auto", "sev1"), true); | |
| 90 | + | assert.equal(wantsNotify("auto", "sev3"), false); | |
| 91 | + | assert.equal(wantsNotify("yes", "sev4"), true); | |
| 92 | + | assert.equal(wantsNotify("no", "sev1"), false); | |
| 93 | + | assert.equal(nextStatus("investigating"), "identified"); | |
| 94 | + | assert.equal(nextStatus("monitoring"), "resolved"); | |
| 95 | + | assert.deepEqual(staffSuggestions("syntaqx@gmail.com, @g1t.sh", ["Ana@g1t.sh", null, "syntaqx@gmail.com"]), ["ana@g1t.sh", "syntaqx@gmail.com"]); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test("the status page's audit lines join the Audit log and link to their pages", () => { | |
| 99 | + | const lines = statusAuditActions([ | |
| 100 | + | { id: "a1", at: "2026-10-05T12:00:00Z", by: "ana@g1t.sh", action: "incident_declared", target: "abc123", detail: "SEV2: Pushes failing" }, | |
| 101 | + | { id: "a2", at: "2026-10-05T10:00:00Z", by: "ana@g1t.sh", action: "maintenance_scheduled", target: "m1", detail: "Upgrade" }, | |
| 102 | + | ]); | |
| 103 | + | assert.equal(accountPath(lines[0]!.account), "/incidents/abc123"); | |
| 104 | + | assert.equal(accountPath(lines[1]!.account), "/incidents/maintenance/m1"); | |
| 105 | + | assert.equal(accountName(lines[0]!.account), "Incident"); | |
| 106 | + | const billing = [{ id: "b", account: "ws_acme", action: "terms", detail: "", by: "x", createdAt: "2026-10-05T11:00:00Z" }]; | |
| 107 | + | assert.deepEqual(mergeAudit(billing, lines).map((l) => l.id), ["status-a1", "b", "status-a2"]); | |
| 108 | + | assert.equal(mergeAudit(billing, lines, 2).length, 2); | |
| 109 | + | }); | |
| 110 | + | ||
| 111 | + | test("the postmortem preview reads text as the status page does", () => { | |
| 112 | + | assert.deepEqual(proseBlocks("One\ntwo\n\n- a\n- b\n\n\n"), [ | |
| 113 | + | { kind: "paragraph", text: "One\ntwo" }, | |
| 114 | + | { kind: "list", items: ["a", "b"] }, | |
| 115 | + | ]); | |
| 116 | + | assert.deepEqual(proseBlocks(""), []); | |
| 117 | + | }); |
| 1 | + | /** | |
| 2 | + | * The Incidents pages' wording and arithmetic: tabs and filters read from | |
| 3 | + | * the address, chips and their tones, durations, and reading the forms. | |
| 4 | + | * No Workers or React imports, so it is tested under Node. The status | |
| 5 | + | * worker checks everything again. | |
| 6 | + | */ | |
| 7 | + | import { | |
| 8 | + | type AdminIncident, | |
| 9 | + | type AdminMaintenance, | |
| 10 | + | COMPONENT_IMPACTS, | |
| 11 | + | type ComponentImpact, | |
| 12 | + | INCIDENT_SEVERITIES, | |
| 13 | + | INCIDENT_STATUSES, | |
| 14 | + | type ImpactInput, | |
| 15 | + | type IncidentSeverity, | |
| 16 | + | type IncidentStatus, | |
| 17 | + | type MaintenanceState, | |
| 18 | + | } from "@g1t/contracts/status"; | |
| 19 | + | ||
| 20 | + | export { COMPONENT_IMPACTS, INCIDENT_SEVERITIES, INCIDENT_STATUSES }; | |
| 21 | + | ||
| 22 | + | export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; | |
| 23 | + | ||
| 24 | + | export const SEVERITY_TONE: Record<IncidentSeverity, Tone> = { sev1: "danger", sev2: "warn", sev3: "info", sev4: "plain" }; | |
| 25 | + | export const STATUS_TONE: Record<IncidentStatus, Tone> = { investigating: "danger", identified: "warn", monitoring: "info", resolved: "mint" }; | |
| 26 | + | export const IMPACT_TONE: Record<ComponentImpact, Tone> = { operational: "mint", degraded: "warn", partial_outage: "warn", major_outage: "danger" }; | |
| 27 | + | export const MAINTENANCE_TONE: Record<MaintenanceState, Tone> = { scheduled: "info", in_progress: "lavender", completed: "mint", cancelled: "plain" }; | |
| 28 | + | export const MAINTENANCE_WORD: Record<MaintenanceState, string> = { | |
| 29 | + | scheduled: "Scheduled", | |
| 30 | + | in_progress: "In progress", | |
| 31 | + | completed: "Completed", | |
| 32 | + | cancelled: "Cancelled", | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | export function severityLabel(severity: IncidentSeverity): string { | |
| 36 | + | return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.label ?? severity.toUpperCase(); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export function statusLabel(status: IncidentStatus): string { | |
| 40 | + | return INCIDENT_STATUSES.find((s) => s.value === status)?.label ?? status; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | export function impactLabel(impact: ComponentImpact): string { | |
| 44 | + | return COMPONENT_IMPACTS.find((i) => i.value === impact)?.label ?? impact; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function notifyByDefault(severity: IncidentSeverity): boolean { | |
| 48 | + | return INCIDENT_SEVERITIES.find((s) => s.value === severity)?.notify ?? false; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /** A notify choice: `yes`, `no`, or `auto` (the severity's default). */ | |
| 52 | + | export function wantsNotify(choice: string, severity: IncidentSeverity): boolean { | |
| 53 | + | return choice === "yes" || choice === "on" || (choice === "auto" && notifyByDefault(severity)); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** The status a next update most likely moves to. */ | |
| 57 | + | export function nextStatus(status: IncidentStatus): IncidentStatus { | |
| 58 | + | return status === "investigating" ? "identified" : status === "identified" ? "monitoring" : "resolved"; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // --- Tabs and filters ------------------------------------------------------------------ | |
| 62 | + | ||
| 63 | + | export const TABS = [ | |
| 64 | + | { value: "open", label: "Open" }, | |
| 65 | + | { value: "maintenance", label: "Scheduled maintenance" }, | |
| 66 | + | { value: "resolved", label: "Resolved" }, | |
| 67 | + | { value: "drafts", label: "Drafts" }, | |
| 68 | + | ] as const; | |
| 69 | + | export type Tab = (typeof TABS)[number]["value"]; | |
| 70 | + | ||
| 71 | + | export type Filters = { tab: Tab; severity: IncidentSeverity | null; component: string | null; q: string }; | |
| 72 | + | ||
| 73 | + | export function parseFilters(params: URLSearchParams): Filters { | |
| 74 | + | const tab = TABS.find((t) => t.value === params.get("tab"))?.value ?? "open"; | |
| 75 | + | const severity = INCIDENT_SEVERITIES.find((s) => s.value === params.get("severity"))?.value ?? null; | |
| 76 | + | const component = /^[a-z][a-z0-9-]{0,40}$/.test(params.get("component") ?? "") ? params.get("component") : null; | |
| 77 | + | const q = (params.get("q") ?? "").trim().slice(0, 100); | |
| 78 | + | return { tab, severity, component, q }; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | export function incidentsHref(filters: Partial<Filters>): string { | |
| 82 | + | const params = new URLSearchParams(); | |
| 83 | + | if (filters.tab && filters.tab !== "open") params.set("tab", filters.tab); | |
| 84 | + | if (filters.severity) params.set("severity", filters.severity); | |
| 85 | + | if (filters.component) params.set("component", filters.component); | |
| 86 | + | if (filters.q) params.set("q", filters.q); | |
| 87 | + | const query = params.toString(); | |
| 88 | + | return query ? `/incidents?${query}` : "/incidents"; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** Which tab an incident is under. Maintenance has its own. */ | |
| 92 | + | export function tabOf(incident: Pick<AdminIncident, "visibility" | "resolved_at">): Exclude<Tab, "maintenance"> { | |
| 93 | + | if (incident.visibility === "draft") return "drafts"; | |
| 94 | + | if (incident.visibility === "dismissed" || incident.resolved_at) return "resolved"; | |
| 95 | + | return "open"; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | export function filterIncidents<T extends Pick<AdminIncident, "visibility" | "resolved_at" | "severity" | "components" | "title">>( | |
| 99 | + | list: T[], | |
| 100 | + | filters: Filters, | |
| 101 | + | ): T[] { | |
| 102 | + | const q = filters.q.toLowerCase(); | |
| 103 | + | return list.filter( | |
| 104 | + | (i) => | |
| 105 | + | tabOf(i) === filters.tab && | |
| 106 | + | (!filters.severity || i.severity === filters.severity) && | |
| 107 | + | (!filters.component || i.components.some((c) => c.key === filters.component && c.impact !== "operational")) && | |
| 108 | + | (!q || i.title.toLowerCase().includes(q)), | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** Maintenance under the tab: upcoming and under way first, soonest first; then finished, newest first. */ | |
| 113 | + | export function maintenanceList(list: AdminMaintenance[], component: string | null): AdminMaintenance[] { | |
| 114 | + | const live = (m: AdminMaintenance) => m.state === "scheduled" || m.state === "in_progress"; | |
| 115 | + | return list | |
| 116 | + | .filter((m) => !component || m.components.includes(component)) | |
| 117 | + | .sort((a, b) => (live(a) !== live(b) ? (live(a) ? -1 : 1) : live(a) ? a.starts_at.localeCompare(b.starts_at) : b.starts_at.localeCompare(a.starts_at))); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | export function tabCounts(incidents: Pick<AdminIncident, "visibility" | "resolved_at">[], maintenance: Pick<AdminMaintenance, "state">[]): Record<Tab, number> { | |
| 121 | + | const counts: Record<Tab, number> = { open: 0, maintenance: 0, resolved: 0, drafts: 0 }; | |
| 122 | + | for (const i of incidents) counts[tabOf(i)] += 1; | |
| 123 | + | counts.maintenance = maintenance.filter((m) => m.state === "scheduled" || m.state === "in_progress").length; | |
| 124 | + | return counts; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // --- Time ---------------------------------------------------------------------------- | |
| 128 | + | ||
| 129 | + | /** "45s", "12m", "2h 05m", "3d 4h"; a dash for none. */ | |
| 130 | + | export function duration(seconds: number | null | undefined): string { | |
| 131 | + | if (seconds == null || Number.isNaN(seconds)) return "—"; | |
| 132 | + | const s = Math.max(0, Math.round(seconds)); | |
| 133 | + | if (s < 60) return `${s}s`; | |
| 134 | + | const minutes = Math.floor(s / 60); | |
| 135 | + | if (minutes < 60) return `${minutes}m`; | |
| 136 | + | const hours = Math.floor(minutes / 60); | |
| 137 | + | if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; | |
| 138 | + | return `${Math.floor(hours / 24)}d ${hours % 24}h`; | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | export function secondsBetween(from: string, to: Date | string): number { | |
| 142 | + | return (new Date(to).getTime() - Date.parse(from)) / 1000; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /** The median of the known values; null with none. */ | |
| 146 | + | export function median(values: (number | null)[]): number | null { | |
| 147 | + | const known = values.filter((v): v is number => v != null).sort((a, b) => a - b); | |
| 148 | + | if (!known.length) return null; | |
| 149 | + | const mid = Math.floor(known.length / 2); | |
| 150 | + | return known.length % 2 ? known[mid]! : (known[mid - 1]! + known[mid]!) / 2; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /** A `datetime-local` value, read as UTC: sudo ships no script to know the browser's zone. */ | |
| 154 | + | export function utc(raw: string): string | null { | |
| 155 | + | const value = raw.trim(); | |
| 156 | + | if (!value) return null; | |
| 157 | + | const iso = /Z$|[+-]\d\d:\d\d$/.test(value) ? value : `${value.length === 16 ? `${value}:00` : value}Z`; | |
| 158 | + | return Number.isNaN(Date.parse(iso)) ? null : new Date(iso).toISOString(); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /** A time as a `datetime-local` value in UTC: `2026-10-05T14:00`. */ | |
| 162 | + | export function localValue(at: Date | string): string { | |
| 163 | + | return new Date(at).toISOString().slice(0, 16); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // --- Forms ------------------------------------------------------------------------------ | |
| 167 | + | ||
| 168 | + | /** Each part's impact from `impact.<key>` selects; an empty choice leaves the part as it is. */ | |
| 169 | + | export function readImpacts(form: FormData, keys: string[]): ImpactInput[] { | |
| 170 | + | const out: ImpactInput[] = []; | |
| 171 | + | for (const key of keys) { | |
| 172 | + | const value = form.get(`impact.${key}`); | |
| 173 | + | const impact = COMPONENT_IMPACTS.find((i) => i.value === value)?.value; | |
| 174 | + | if (impact) out.push({ key, impact }); | |
| 175 | + | } | |
| 176 | + | return out; | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /** Staff addresses for the roles' suggestions: exact addresses in STAFF_EMAILS, and any seen. */ | |
| 180 | + | export function staffSuggestions(staffEmails: string, seen: (string | null | undefined)[]): string[] { | |
| 181 | + | const listed = staffEmails | |
| 182 | + | .split(",") | |
| 183 | + | .map((e) => e.trim().toLowerCase()) | |
| 184 | + | .filter((e) => /^[^@\s]+@[^@\s]+$/.test(e)); | |
| 185 | + | return [...new Set([...listed, ...seen.filter((e): e is string => !!e && e.includes("@")).map((e) => e.toLowerCase())])].sort(); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /** The chip an incident's phase shows: draft, dismissed, postmortem published, or its status. */ | |
| 189 | + | export function phase(i: Pick<AdminIncident, "visibility" | "status" | "postmortem_published_at">): { label: string; tone: Tone } { | |
| 190 | + | if (i.visibility === "draft") return { label: "Draft", tone: "lavender" }; | |
| 191 | + | if (i.visibility === "dismissed") return { label: "Dismissed", tone: "plain" }; | |
| 192 | + | if (i.postmortem_published_at) return { label: "Postmortem published", tone: "mint" }; | |
| 193 | + | return { label: statusLabel(i.status), tone: STATUS_TONE[i.status] }; | |
| 194 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { INVITES_DONE, invitesHref, parseGrant, parseMintEmail, parseTab, parseWaitlistStatus } from "./invites.ts"; | |
| 4 | + | import { | |
| 5 | + | INVITES_DONE, | |
| 6 | + | MAX_BULK, | |
| 7 | + | doneMessage, | |
| 8 | + | invitesHref, | |
| 9 | + | parseGrant, | |
| 10 | + | parseIds, | |
| 11 | + | parseMintEmail, | |
| 12 | + | parseNote, | |
| 13 | + | parseTab, | |
| 14 | + | parseWaitlistStatus, | |
| 15 | + | } from "./invites.ts"; | |
| 5 | 16 | ||
| 6 | 17 | const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null }); | |
| 7 | 18 | ||
| 41 | 52 | test("every outcome has words", () => { | |
| 42 | 53 | for (const key of ["approved", "dismissed", "revoked", "granted"]) assert.ok(INVITES_DONE[key]); | |
| 43 | 54 | }); | |
| 55 | + | ||
| 56 | + | test("several requests are decided at once, each once", () => { | |
| 57 | + | const ticked = (ids: string[]) => ({ getAll: (name: string) => (name === "ids" ? ids : []) }); | |
| 58 | + | assert.deepEqual(parseIds(ticked(["wl_01jb2k", "wl_01jb2m", "wl_01jb2k"])), { ok: true, value: ["wl_01jb2k", "wl_01jb2m"] }); | |
| 59 | + | assert.equal(parseIds(ticked([])).ok, false); | |
| 60 | + | assert.equal(parseIds(ticked(["inv_01jb2k", "wl_<script>"])).ok, false); | |
| 61 | + | const many = Array.from({ length: MAX_BULK + 1 }, (_, n) => `wl_${n.toString(36)}`); | |
| 62 | + | assert.equal(parseIds(ticked(many)).ok, false); | |
| 63 | + | assert.equal(parseIds(ticked(many.slice(0, MAX_BULK))).ok, true); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("an approval's note is optional and short", () => { | |
| 67 | + | assert.deepEqual(parseNote(" Welcome aboard. "), { ok: true, value: "Welcome aboard." }); | |
| 68 | + | assert.deepEqual(parseNote(""), { ok: true, value: null }); | |
| 69 | + | assert.deepEqual(parseNote(null), { ok: true, value: null }); | |
| 70 | + | assert.equal(parseNote("x".repeat(501)).ok, false); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("the flash says how many were decided", () => { | |
| 74 | + | assert.equal(doneMessage("approved", "3"), "Approved 3 requests. Each invite is on its way."); | |
| 75 | + | assert.equal(doneMessage("dismissed", "2"), "Dismissed 2 requests."); | |
| 76 | + | assert.equal(doneMessage("approved", null), INVITES_DONE.approved); | |
| 77 | + | assert.equal(doneMessage("approved", "1"), INVITES_DONE.approved); | |
| 78 | + | assert.equal(doneMessage("nope", "4"), null); | |
| 79 | + | assert.equal(doneMessage(null, null), null); | |
| 80 | + | }); |
| 74 | 74 | revoked: "Invite revoked. It comes back to whoever it was charged to.", | |
| 75 | 75 | granted: "Granted. It applies at once.", | |
| 76 | 76 | }; | |
| 77 | + | ||
| 78 | + | /** The flash for `?done=` and, after deciding several at once, `?n=` of them. */ | |
| 79 | + | export function doneMessage(done: string | null, n: string | null): string | null { | |
| 80 | + | if (!done) return null; | |
| 81 | + | const count = Number(n); | |
| 82 | + | if (Number.isInteger(count) && count > 1) { | |
| 83 | + | if (done === "approved") return `Approved ${count} requests. Each invite is on its way.`; | |
| 84 | + | if (done === "dismissed") return `Dismissed ${count} requests.`; | |
| 85 | + | } | |
| 86 | + | return INVITES_DONE[done] ?? null; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** The most requests one bulk decision takes: each one is its own call to identity. */ | |
| 90 | + | export const MAX_BULK = 50; | |
| 91 | + | ||
| 92 | + | /** The waitlist entries ticked on a bulk form, each once, in order. */ | |
| 93 | + | export function parseIds(form: { getAll(name: string): unknown[] }): Parsed<string[]> { | |
| 94 | + | const ids = [ | |
| 95 | + | ...new Set( | |
| 96 | + | form | |
| 97 | + | .getAll("ids") | |
| 98 | + | .filter((id): id is string => typeof id === "string") | |
| 99 | + | .map((id) => id.trim()) | |
| 100 | + | .filter((id) => /^wl_[0-9a-z]{1,40}$/.test(id)), | |
| 101 | + | ), | |
| 102 | + | ]; | |
| 103 | + | if (ids.length === 0) return { ok: false, error: "Tick the requests to decide on first." }; | |
| 104 | + | if (ids.length > MAX_BULK) return { ok: false, error: `Decide on at most ${MAX_BULK} at a time.` }; | |
| 105 | + | return { ok: true, value: ids }; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /** The most characters an approval's note keeps; identity holds the same line. */ | |
| 109 | + | export const MAX_NOTE = 500; | |
| 110 | + | ||
| 111 | + | /** A note for the invite email: trimmed, or none. */ | |
| 112 | + | export function parseNote(raw: string | null | undefined): Parsed<string | null> { | |
| 113 | + | const note = (raw ?? "").trim(); | |
| 114 | + | if (!note) return { ok: true, value: null }; | |
| 115 | + | if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note to ${MAX_NOTE} characters; it is ${note.length}.` }; | |
| 116 | + | return { ok: true, value: note }; | |
| 117 | + | } |
| 67 | 67 | */ | |
| 68 | 68 | export function accountPath(account: string | null | undefined): string | null { | |
| 69 | 69 | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | + | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); | |
| 71 | + | if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`; | |
| 70 | 72 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| 71 | 73 | if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`; | |
| 72 | 74 | return null; | |
| 75 | 77 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 76 | 78 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 77 | 79 | if (names.has(account)) return names.get(account) as string; | |
| 80 | + | if (account.startsWith("incident:")) return "Incident"; | |
| 81 | + | if (account.startsWith("maintenance:")) return "Maintenance"; | |
| 78 | 82 | if (account.startsWith("ws_")) return account.slice(3); | |
| 79 | 83 | if (ENTERPRISE.test(account)) return "an enterprise"; | |
| 80 | 84 | return account; | |
| 101 | 105 | note: "Sales note added", | |
| 102 | 106 | stripe: "From Stripe", | |
| 103 | 107 | webhook: "Stripe webhook registered", | |
| 108 | + | // From the status page (apps/status), merged in by the Audit log page. | |
| 109 | + | incident_declared: "Incident declared", | |
| 110 | + | incident_detected: "Incident detected", | |
| 111 | + | incident_update: "Incident update", | |
| 112 | + | incident_note: "Incident note", | |
| 113 | + | incident_resolved: "Incident resolved", | |
| 114 | + | incident_roles: "Incident roles", | |
| 115 | + | incident_published: "Incident published", | |
| 116 | + | incident_dismissed: "Draft dismissed", | |
| 117 | + | incident_followup: "Incident follow-up", | |
| 118 | + | postmortem_saved: "Postmortem saved", | |
| 119 | + | postmortem_published: "Postmortem published", | |
| 120 | + | postmortem_unpublished: "Postmortem taken down", | |
| 121 | + | maintenance_scheduled: "Maintenance scheduled", | |
| 122 | + | maintenance_update: "Maintenance update", | |
| 123 | + | maintenance_started: "Maintenance started", | |
| 124 | + | maintenance_completed: "Maintenance completed", | |
| 125 | + | maintenance_cancelled: "Maintenance cancelled", | |
| 104 | 126 | }; | |
| 105 | 127 | ||
| 128 | + | /** | |
| 129 | + | * The status page's audit lines as the Audit log lists billing's: the | |
| 130 | + | * account is `incident:<id>` or `maintenance:<id>`, which link to their | |
| 131 | + | * pages in sudo. | |
| 132 | + | */ | |
| 133 | + | export function statusAuditActions(entries: { id: string; at: string; by: string; action: string; target: string; detail: string }[]): AdminAction[] { | |
| 134 | + | return entries.map((e) => ({ | |
| 135 | + | id: `status-${e.id}`, | |
| 136 | + | account: `${e.action.startsWith("maintenance_") ? "maintenance" : "incident"}:${e.target}`, | |
| 137 | + | action: e.action, | |
| 138 | + | detail: e.detail, | |
| 139 | + | by: e.by, | |
| 140 | + | createdAt: e.at, | |
| 141 | + | })); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /** Billing's lines and the status page's, newest first, one page's worth. */ | |
| 145 | + | export function mergeAudit(a: AdminAction[], b: AdminAction[], page = AUDIT_PAGE): AdminAction[] { | |
| 146 | + | return [...a, ...b].sort((x, y) => y.createdAt.localeCompare(x.createdAt)).slice(0, page); | |
| 147 | + | } | |
| 148 | + | ||
| 106 | 149 | export function actionLabel(action: string): string { | |
| 107 | 150 | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 108 | 151 | } |
| 1 | + | /** | |
| 2 | + | * The postmortem editor's sections, and its preview's reading of plain | |
| 3 | + | * text, the same way the status page reads it (apps/status render.ts | |
| 4 | + | * `prose`). No Workers or React imports, so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { PostmortemFields } from "@g1t/contracts/status"; | |
| 7 | + | ||
| 8 | + | export const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string; hint: string; rows: number }[] = [ | |
| 9 | + | { key: "summary", title: "Summary", hint: "Two or three sentences: what happened, to whom, for how long. Required to publish.", rows: 4 }, | |
| 10 | + | { key: "impact", title: "Impact", hint: "Who noticed, and what they could not do.", rows: 3 }, | |
| 11 | + | { key: "timeline", title: "Timeline", hint: "Filled in from the incident's timeline. Times are UTC.", rows: 8 }, | |
| 12 | + | { key: "root_cause", title: "Root cause", hint: "Why it happened, not who. Required to publish.", rows: 4 }, | |
| 13 | + | { key: "went_well", title: "What went well", hint: "", rows: 3 }, | |
| 14 | + | { key: "went_badly", title: "What went badly", hint: "", rows: 3 }, | |
| 15 | + | { key: "action_items", title: "Action items", hint: "Filled in from the follow-ups. One per line, starting “- ”.", rows: 4 }, | |
| 16 | + | ]; | |
| 17 | + | ||
| 18 | + | export type Block = { kind: "paragraph"; text: string } | { kind: "list"; items: string[] }; | |
| 19 | + | ||
| 20 | + | /** Blank lines between paragraphs; a block whose lines all start with "- " is a list. */ | |
| 21 | + | export function proseBlocks(text: string): Block[] { | |
| 22 | + | return text | |
| 23 | + | .split(/\n{2,}/) | |
| 24 | + | .map((block) => block.replace(/^\n+|\n+$/g, "")) | |
| 25 | + | .filter(Boolean) | |
| 26 | + | .map((block): Block => { | |
| 27 | + | const lines = block.split("\n").filter((l) => l.trim() !== ""); | |
| 28 | + | if (lines.every((l) => /^\s*[-*] /.test(l))) return { kind: "list", items: lines.map((l) => l.replace(/^\s*[-*] /, "")) }; | |
| 29 | + | return { kind: "paragraph", text: block }; | |
| 30 | + | }); | |
| 31 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { accountsAdminClient, billingAdminClient, billingClient, eventsClient, identityAdminClient } from "@g1t/contracts"; | |
| 3 | + | import { | |
| 4 | + | type StatusAdminApi, | |
| 5 | + | accountsAdminClient, | |
| 6 | + | billingAdminClient, | |
| 7 | + | billingClient, | |
| 8 | + | eventsClient, | |
| 9 | + | identityAdminClient, | |
| 10 | + | } from "@g1t/contracts"; | |
| 4 | 11 | ||
| 5 | 12 | /** Staff-only billing, on the billing service. */ | |
| 6 | 13 | export const admin = billingAdminClient(env.BILLING); | |
| 19 | 26 | ||
| 20 | 27 | /** The event log, read-only: sudo lists `abuse.flagged`. */ | |
| 21 | 28 | export const events = eventsClient(env.EVENTS); | |
| 29 | + | ||
| 30 | + | /** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */ | |
| 31 | + | export const statusAdmin = env.STATUS as unknown as StatusAdminApi; | |
| 32 | + | ||
| 33 | + | /** STAFF_EMAILS, for suggesting staff in the incident roles. */ | |
| 34 | + | export const staffEmails = (): string => env.STAFF_EMAILS ?? ""; |
| 6 | 6 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 7 | 7 | import { MobileBar, Sidebar } from "./components/shell"; | |
| 8 | 8 | import { ButtonLink } from "./components/ui"; | |
| 9 | + | import type { NavCounts } from "./lib/nav"; | |
| 10 | + | import { identity, statusAdmin } from "./lib/services.server"; | |
| 11 | + | import { settle } from "./lib/settle"; | |
| 9 | 12 | import { requireStaff } from "./lib/staff"; | |
| 10 | 13 | ||
| 11 | 14 | export const links: Route.LinksFunction = () => [ | |
| 21 | 24 | ]; | |
| 22 | 25 | ||
| 23 | 26 | export async function loader({ context }: Route.LoaderArgs) { | |
| 24 | − | return { email: requireStaff(context).email }; | |
| 27 | + | const { email } = requireStaff(context); | |
| 28 | + | // The sidebar's counts: a service that does not answer shows none. | |
| 29 | + | const [waitlist, incidents] = await Promise.all([settle(identity.waitlistPending()), settle(statusAdmin.openCount())]); | |
| 30 | + | const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 }; | |
| 31 | + | return { email, counts }; | |
| 25 | 32 | } | |
| 26 | 33 | ||
| 27 | 34 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 36 | 43 | <Links /> | |
| 37 | 44 | </head> | |
| 38 | 45 | <body className="min-h-screen"> | |
| 39 | − | <Sidebar email={root?.email} /> | |
| 40 | − | <MobileBar email={root?.email} /> | |
| 46 | + | <Sidebar email={root?.email} counts={root?.counts} /> | |
| 47 | + | <MobileBar email={root?.email} counts={root?.counts} /> | |
| 41 | 48 | <div className="lg:pl-60">{children}</div> | |
| 42 | 49 | {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */} | |
| 43 | 50 | </body> |
| 19 | 19 | route("invoices", "routes/invoices.tsx"), | |
| 20 | 20 | route("stripe", "routes/stripe.tsx"), | |
| 21 | 21 | route("audit", "routes/audit.tsx"), | |
| 22 | + | route("incidents", "routes/incidents.tsx"), | |
| 23 | + | route("incidents/new", "routes/incident-new.tsx"), | |
| 24 | + | route("incidents/maintenance/new", "routes/maintenance-new.tsx"), | |
| 25 | + | route("incidents/maintenance/:id", "routes/maintenance.tsx"), | |
| 26 | + | route("incidents/:id", "routes/incident.tsx"), | |
| 27 | + | route("incidents/:id/postmortem", "routes/incident-postmortem.tsx"), | |
| 22 | 28 | // What sudo will have: one placeholder page each, from lib/nav.ts. | |
| 23 | 29 | ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })), | |
| 24 | 30 | // Before sudo was organised around workspaces, everything was an account. |
| 12 | 12 | accountPath, | |
| 13 | 13 | actionLabel, | |
| 14 | 14 | auditHref, | |
| 15 | + | mergeAudit, | |
| 15 | 16 | olderBefore, | |
| 16 | 17 | parseAction, | |
| 17 | 18 | parseBefore, | |
| 18 | 19 | parseBy, | |
| 20 | + | statusAuditActions, | |
| 19 | 21 | } from "~/lib/ledgers"; | |
| 20 | − | import { admin } from "~/lib/services.server"; | |
| 22 | + | import { admin, statusAdmin } from "~/lib/services.server"; | |
| 21 | 23 | import { settle } from "~/lib/settle"; | |
| 22 | 24 | import { requireStaff } from "~/lib/staff"; | |
| 23 | 25 | ||
| 30 | 32 | const action = parseAction(url.searchParams.get("action")); | |
| 31 | 33 | const before = parseBefore(url.searchParams.get("before")); | |
| 32 | 34 | // Enterprises' names, so a line about one reads as its name, not its id. | |
| 33 | − | const [result, accounts] = await Promise.all([ | |
| 35 | + | // The status page keeps its own log (incidents, maintenance, postmortems), merged in here. | |
| 36 | + | const [result, accounts, status] = await Promise.all([ | |
| 34 | 37 | settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })), | |
| 35 | 38 | settle(admin.accounts()), | |
| 39 | + | settle(statusAdmin.audit({ before })), | |
| 36 | 40 | ]); | |
| 37 | 41 | const names = Object.fromEntries( | |
| 38 | 42 | (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]), | |
| 39 | 43 | ); | |
| 40 | − | const actions = result.ok ? result.value : []; | |
| 44 | + | const fromStatus = statusAuditActions(status.ok ? status.value : []).filter( | |
| 45 | + | (entry) => (!by || entry.by.toLowerCase() === by) && (!action || entry.action === action), | |
| 46 | + | ); | |
| 47 | + | const actions = mergeAudit(result.ok ? result.value : [], fromStatus); | |
| 41 | 48 | return { | |
| 42 | 49 | me: staff.email, | |
| 43 | 50 | by, | |
| 64 | 71 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 65 | 72 | <PageHeader | |
| 66 | 73 | title="Audit log" | |
| 67 | − | description="Every change made in sudo, and what Stripe told billing, newest first, across every customer. Each names who made it." | |
| 74 | + | description="Every change made in sudo, what Stripe told billing, and every incident and maintenance change on the status page, newest first. Each names who made it." | |
| 68 | 75 | /> | |
| 69 | 76 | ||
| 70 | 77 | <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center"> |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { IncidentSeverity, IncidentStatus } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/incident-new"; | |
| 6 | + | import { BackLink, ImpactPicker } from "~/components/incidents"; | |
| 7 | + | import { Button, Field, Input, Notice, PageHeader, Section, Select, Textarea } from "~/components/ui"; | |
| 8 | + | import { text } from "~/lib/forms"; | |
| 9 | + | import { INCIDENT_SEVERITIES, INCIDENT_STATUSES, readImpacts, staffSuggestions, utc, wantsNotify } from "~/lib/incidents"; | |
| 10 | + | import { staffEmails, statusAdmin } from "~/lib/services.server"; | |
| 11 | + | import { settle } from "~/lib/settle"; | |
| 12 | + | import { requireStaff } from "~/lib/staff"; | |
| 13 | + | ||
| 14 | + | export const meta: Route.MetaFunction = () => [{ title: "Declare an incident · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | + | ||
| 16 | + | export async function loader({ context }: Route.LoaderArgs) { | |
| 17 | + | const staff = requireStaff(context); | |
| 18 | + | const components = await settle(statusAdmin.components()); | |
| 19 | + | return { | |
| 20 | + | me: staff.email, | |
| 21 | + | components: components.ok ? components.value : [], | |
| 22 | + | error: components.ok ? null : components.error, | |
| 23 | + | staff: staffSuggestions(staffEmails(), [staff.email]), | |
| 24 | + | }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type ActionData = { error: string; values: Record<string, string> }; | |
| 28 | + | ||
| 29 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 30 | + | const staff = requireStaff(context); | |
| 31 | + | const form = await request.formData(); | |
| 32 | + | const components = await settle(statusAdmin.components()); | |
| 33 | + | const keys = components.ok ? components.value.map((c) => c.key) : []; | |
| 34 | + | const values = Object.fromEntries(["title", "severity", "status", "message", "started_at", "commander", "communications", "notify"].map((k) => [k, text(form, k)])); | |
| 35 | + | const result = await settle( | |
| 36 | + | statusAdmin.declare({ | |
| 37 | + | title: text(form, "title"), | |
| 38 | + | severity: text(form, "severity") as IncidentSeverity, | |
| 39 | + | status: text(form, "status") as IncidentStatus, | |
| 40 | + | components: readImpacts(form, keys), | |
| 41 | + | message: String(form.get("message") ?? ""), | |
| 42 | + | started_at: utc(text(form, "started_at")), | |
| 43 | + | commander: text(form, "commander") || null, | |
| 44 | + | communications: text(form, "communications") || null, | |
| 45 | + | notify: wantsNotify(text(form, "notify"), text(form, "severity") as IncidentSeverity), | |
| 46 | + | by: staff.email, | |
| 47 | + | }), | |
| 48 | + | ); | |
| 49 | + | if (!result.ok) return { error: result.error, values } satisfies ActionData; | |
| 50 | + | if (!result.value.ok) return { error: result.value.error.message, values } satisfies ActionData; | |
| 51 | + | return redirect(`/incidents/${result.value.value.id}?declared=1`); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | export default function DeclareIncident({ loaderData, actionData }: Route.ComponentProps) { | |
| 55 | + | const { me, components, error, staff } = loaderData; | |
| 56 | + | const failed = actionData as ActionData | undefined; | |
| 57 | + | const v = failed?.values ?? {}; | |
| 58 | + | ||
| 59 | + | return ( | |
| 60 | + | <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10"> | |
| 61 | + | <BackLink to="/incidents">Incidents</BackLink> | |
| 62 | + | <div className="mt-3"> | |
| 63 | + | <PageHeader | |
| 64 | + | title="Declare an incident" | |
| 65 | + | description="It goes on status.g1t.sh at once with its first update, and the parts you mark show their impact there until it is resolved." | |
| 66 | + | /> | |
| 67 | + | </div> | |
| 68 | + | <div className="mt-5 space-y-3"> | |
| 69 | + | {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>} | |
| 70 | + | {failed && <Notice tone="error">{failed.error}</Notice>} | |
| 71 | + | </div> | |
| 72 | + | ||
| 73 | + | <form method="post" className="mt-6 space-y-6"> | |
| 74 | + | <Section title="What is happening"> | |
| 75 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 76 | + | <Field label="Title" className="sm:col-span-2" hint="What people notice, not the internals. Shown publicly."> | |
| 77 | + | <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Pushes over HTTPS failing" /> | |
| 78 | + | </Field> | |
| 79 | + | <fieldset className="sm:col-span-2"> | |
| 80 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Severity</legend> | |
| 81 | + | <div className="grid gap-2 sm:grid-cols-2"> | |
| 82 | + | {INCIDENT_SEVERITIES.map((s) => ( | |
| 83 | + | <label | |
| 84 | + | key={s.value} | |
| 85 | + | className="flex cursor-pointer gap-2.5 rounded-md border border-line px-3 py-2 has-checked:border-merged/60 has-checked:bg-merged/8" | |
| 86 | + | > | |
| 87 | + | <input type="radio" name="severity" value={s.value} required defaultChecked={(v.severity || "sev3") === s.value} className="mt-1 accent-[#b6a8ff]" /> | |
| 88 | + | <span> | |
| 89 | + | <span className="block text-sm font-semibold">{s.label}</span> | |
| 90 | + | <span className="block text-xs text-muted">{s.about}</span> | |
| 91 | + | </span> | |
| 92 | + | </label> | |
| 93 | + | ))} | |
| 94 | + | </div> | |
| 95 | + | </fieldset> | |
| 96 | + | <Field label="Status" hint="Investigating: the cause is not known yet. Identified: a fix is under way. Monitoring: a fix is out."> | |
| 97 | + | <Select name="status" defaultValue={v.status || "investigating"}> | |
| 98 | + | {INCIDENT_STATUSES.filter((s) => s.value !== "resolved").map((s) => ( | |
| 99 | + | <option key={s.value} value={s.value}> | |
| 100 | + | {s.label} | |
| 101 | + | </option> | |
| 102 | + | ))} | |
| 103 | + | </Select> | |
| 104 | + | </Field> | |
| 105 | + | <Field label="Impact began (UTC)" hint="Leave empty for now. Up to 90 days back; durations count from here."> | |
| 106 | + | <Input type="datetime-local" name="started_at" defaultValue={v.started_at} /> | |
| 107 | + | </Field> | |
| 108 | + | </div> | |
| 109 | + | </Section> | |
| 110 | + | ||
| 111 | + | <Section title="Parts affected" description="Mark each affected part. They show this on the status page, or worse if their checks say so."> | |
| 112 | + | <ImpactPicker components={components} current={new Map()} /> | |
| 113 | + | </Section> | |
| 114 | + | ||
| 115 | + | <Section title="First public update" description="Shown on the status page with the status above."> | |
| 116 | + | <Textarea | |
| 117 | + | name="message" | |
| 118 | + | rows={4} | |
| 119 | + | required | |
| 120 | + | maxLength={4000} | |
| 121 | + | defaultValue={v.message} | |
| 122 | + | aria-label="First public update" | |
| 123 | + | placeholder="We are looking into reports that pushes over HTTPS fail with a 502. Pulls and the website are not affected." | |
| 124 | + | /> | |
| 125 | + | <Field label="Email subscribers" className="mt-3 sm:w-80" hint="Subscribers who chose other parts are not emailed."> | |
| 126 | + | <Select name="notify" defaultValue={v.notify || "auto"}> | |
| 127 | + | <option value="auto">As the severity says: yes for SEV1 and SEV2</option> | |
| 128 | + | <option value="yes">Yes</option> | |
| 129 | + | <option value="no">No</option> | |
| 130 | + | </Select> | |
| 131 | + | </Field> | |
| 132 | + | </Section> | |
| 133 | + | ||
| 134 | + | <Section title="Roles" description="Staff emails. Change them any time from the incident."> | |
| 135 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 136 | + | <Field label="Incident commander" hint="Runs the response and decides."> | |
| 137 | + | <Input name="commander" type="email" list="staff" defaultValue={v.commander ?? me} placeholder={me} /> | |
| 138 | + | </Field> | |
| 139 | + | <Field label="Communications" hint="Writes the public updates."> | |
| 140 | + | <Input name="communications" type="email" list="staff" defaultValue={v.communications} placeholder="Optional" /> | |
| 141 | + | </Field> | |
| 142 | + | </div> | |
| 143 | + | <datalist id="staff"> | |
| 144 | + | {staff.map((email) => ( | |
| 145 | + | <option key={email} value={email} /> | |
| 146 | + | ))} | |
| 147 | + | </datalist> | |
| 148 | + | </Section> | |
| 149 | + | ||
| 150 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 151 | + | <Button type="submit" variant="lavender"> | |
| 152 | + | Declare and publish | |
| 153 | + | </Button> | |
| 154 | + | <p className="text-xs text-faint">Recorded in the audit log with your email.</p> | |
| 155 | + | </div> | |
| 156 | + | </form> | |
| 157 | + | </main> | |
| 158 | + | ); | |
| 159 | + | } |
| 1 | + | import { ExternalLink } from "lucide-react"; | |
| 2 | + | import { redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { PostmortemFields } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/incident-postmortem"; | |
| 7 | + | import { BackLink, SeverityBadge } from "~/components/incidents"; | |
| 8 | + | import { Badge, Button, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; | |
| 9 | + | import { duration } from "~/lib/incidents"; | |
| 10 | + | import { POSTMORTEM_SECTIONS, proseBlocks } from "~/lib/postmortem"; | |
| 11 | + | import { statusAdmin } from "~/lib/services.server"; | |
| 12 | + | import { settle } from "~/lib/settle"; | |
| 13 | + | import { requireStaff } from "~/lib/staff"; | |
| 14 | + | ||
| 15 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 16 | + | { title: `Postmortem: ${loaderData?.incident?.title ?? "incident"} · sudo` }, | |
| 17 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 18 | + | ]; | |
| 19 | + | ||
| 20 | + | export async function loader({ params, request, context }: Route.LoaderArgs) { | |
| 21 | + | requireStaff(context); | |
| 22 | + | const incident = await settle(statusAdmin.incident(params.id)); | |
| 23 | + | if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 }); | |
| 24 | + | const done = new URL(request.url).searchParams.get("done"); | |
| 25 | + | return { | |
| 26 | + | incident: incident.ok ? incident.value : null, | |
| 27 | + | error: incident.ok ? null : incident.error, | |
| 28 | + | done: done === "saved" ? "Draft saved. The preview shows it." : done === "published" ? "Published on the incident's page." : done === "unpublished" ? "Taken down from the status page." : null, | |
| 29 | + | }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type ActionData = { error: string; values?: PostmortemFields }; | |
| 33 | + | ||
| 34 | + | export async function action({ params, request, context }: Route.ActionArgs) { | |
| 35 | + | const staff = requireStaff(context); | |
| 36 | + | const form = await request.formData(); | |
| 37 | + | const intent = String(form.get("intent") ?? ""); | |
| 38 | + | const values = Object.fromEntries(POSTMORTEM_SECTIONS.map((s) => [s.key, String(form.get(s.key) ?? "")])) as PostmortemFields; | |
| 39 | + | if (intent === "save" || intent === "save_publish") { | |
| 40 | + | const saved = await settle(statusAdmin.savePostmortem(params.id, { ...values, by: staff.email })); | |
| 41 | + | if (!saved.ok) return { error: saved.error, values } satisfies ActionData; | |
| 42 | + | if (!saved.value.ok) return { error: saved.value.error.message, values } satisfies ActionData; | |
| 43 | + | if (intent === "save") return redirect(`/incidents/${params.id}/postmortem?done=saved#preview`); | |
| 44 | + | } | |
| 45 | + | if (intent === "save_publish" || intent === "publish" || intent === "unpublish") { | |
| 46 | + | const publish = intent !== "unpublish"; | |
| 47 | + | const result = await settle(statusAdmin.publishPostmortem(params.id, { publish, by: staff.email })); | |
| 48 | + | if (!result.ok) return { error: result.error } satisfies ActionData; | |
| 49 | + | if (!result.value.ok) return { error: result.value.error.message } satisfies ActionData; | |
| 50 | + | return redirect(`/incidents/${params.id}/postmortem?done=${publish ? "published" : "unpublished"}`); | |
| 51 | + | } | |
| 52 | + | return { error: "Unknown action." } satisfies ActionData; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function Preview({ fields }: { fields: PostmortemFields }) { | |
| 56 | + | const shown = POSTMORTEM_SECTIONS.filter((s) => fields[s.key].trim()); | |
| 57 | + | if (!shown.length) return <p className="text-sm text-muted">Nothing written yet.</p>; | |
| 58 | + | return ( | |
| 59 | + | <div className="space-y-5"> | |
| 60 | + | {shown.map((s) => ( | |
| 61 | + | <section key={s.key}> | |
| 62 | + | <h3 className="text-sm font-semibold">{s.title}</h3> | |
| 63 | + | <div className="mt-1 space-y-2 text-sm text-fg-soft"> | |
| 64 | + | {proseBlocks(fields[s.key]).map((block, i) => | |
| 65 | + | block.kind === "list" ? ( | |
| 66 | + | <ul key={i} className="list-disc space-y-1 pl-5"> | |
| 67 | + | {block.items.map((item, j) => ( | |
| 68 | + | <li key={j}>{item}</li> | |
| 69 | + | ))} | |
| 70 | + | </ul> | |
| 71 | + | ) : ( | |
| 72 | + | <p key={i} className="whitespace-pre-line"> | |
| 73 | + | {block.text} | |
| 74 | + | </p> | |
| 75 | + | ), | |
| 76 | + | )} | |
| 77 | + | </div> | |
| 78 | + | </section> | |
| 79 | + | ))} | |
| 80 | + | </div> | |
| 81 | + | ); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | export default function PostmortemEditor({ loaderData, actionData }: Route.ComponentProps) { | |
| 85 | + | const { incident, error, done } = loaderData; | |
| 86 | + | const failed = actionData as ActionData | undefined; | |
| 87 | + | if (!incident) { | |
| 88 | + | return ( | |
| 89 | + | <main className="mx-auto max-w-6xl px-4 py-8"> | |
| 90 | + | <Notice tone="warn">The status worker did not answer: {error}</Notice> | |
| 91 | + | </main> | |
| 92 | + | ); | |
| 93 | + | } | |
| 94 | + | const saved = incident.postmortem; | |
| 95 | + | const fields: PostmortemFields = failed?.values ?? saved ?? incident.postmortem_draft; | |
| 96 | + | const published = saved?.published_at != null; | |
| 97 | + | const resolved = incident.resolved_at != null; | |
| 98 | + | ||
| 99 | + | return ( | |
| 100 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 101 | + | <BackLink to={`/incidents/${incident.id}`}>{incident.title}</BackLink> | |
| 102 | + | <div className="mt-3"> | |
| 103 | + | <PageHeader | |
| 104 | + | title="Postmortem" | |
| 105 | + | description={ | |
| 106 | + | <> | |
| 107 | + | <SeverityBadge severity={incident.severity} /> <span className="ml-1">{incident.title}</span> · lasted {duration(incident.durations.to_resolve)} | |
| 108 | + | </> | |
| 109 | + | } | |
| 110 | + | actions={ | |
| 111 | + | published ? ( | |
| 112 | + | <a href={`${incident.url}#postmortem`} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline"> | |
| 113 | + | On the status page <ExternalLink size={13} aria-hidden="true" /> | |
| 114 | + | </a> | |
| 115 | + | ) : undefined | |
| 116 | + | } | |
| 117 | + | /> | |
| 118 | + | </div> | |
| 119 | + | <div className="mt-4 space-y-3"> | |
| 120 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 121 | + | {failed && <Notice tone="error">{failed.error}</Notice>} | |
| 122 | + | {!resolved && <Notice tone="info">The incident is still open. Draft away; it can be published once it is resolved.</Notice>} | |
| 123 | + | {!saved && ( | |
| 124 | + | <Notice tone="info"> | |
| 125 | + | Started for you: the impact from its parts and times, the timeline from the incident's own, and the follow-ups as action items. The timeline includes internal notes: edit out anything that should stay inside. | |
| 126 | + | </Notice> | |
| 127 | + | )} | |
| 128 | + | </div> | |
| 129 | + | ||
| 130 | + | <div className="mt-6 grid gap-6 lg:grid-cols-2"> | |
| 131 | + | <form method="post" className="min-w-0 space-y-4"> | |
| 132 | + | <Section title="Write" description="Plain text. A blank line starts a paragraph; lines starting with “- ” become a list."> | |
| 133 | + | <div className="space-y-4"> | |
| 134 | + | {POSTMORTEM_SECTIONS.map((s) => ( | |
| 135 | + | <Field key={s.key} label={s.title} hint={s.hint}> | |
| 136 | + | <Textarea name={s.key} rows={s.rows} maxLength={20000} defaultValue={fields[s.key]} className="font-[inherit]" /> | |
| 137 | + | </Field> | |
| 138 | + | ))} | |
| 139 | + | </div> | |
| 140 | + | </Section> | |
| 141 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 142 | + | <Button type="submit" name="intent" value="save" variant="quiet"> | |
| 143 | + | Save draft | |
| 144 | + | </Button> | |
| 145 | + | {resolved && !published && ( | |
| 146 | + | <Button type="submit" name="intent" value="save_publish" variant="lavender"> | |
| 147 | + | Save and publish | |
| 148 | + | </Button> | |
| 149 | + | )} | |
| 150 | + | {saved && ( | |
| 151 | + | <span className="text-xs text-faint"> | |
| 152 | + | Saved <When at={saved.updated_at} time /> by <span className="font-mono">{saved.updated_by}</span> | |
| 153 | + | </span> | |
| 154 | + | )} | |
| 155 | + | </div> | |
| 156 | + | </form> | |
| 157 | + | ||
| 158 | + | <div className="min-w-0 space-y-4 lg:sticky lg:top-6 lg:self-start"> | |
| 159 | + | <Section | |
| 160 | + | id="preview" | |
| 161 | + | title="Preview" | |
| 162 | + | description={saved ? "As the status page shows it, from the last save." : "As the status page would show it."} | |
| 163 | + | actions={published ? <Badge tone="mint">Published</Badge> : <Badge>Not published</Badge>} | |
| 164 | + | > | |
| 165 | + | <Preview fields={saved ?? fields} /> | |
| 166 | + | </Section> | |
| 167 | + | {saved && ( | |
| 168 | + | <form method="post" className="flex flex-wrap items-center gap-3"> | |
| 169 | + | {published ? ( | |
| 170 | + | <> | |
| 171 | + | <input type="hidden" name="intent" value="unpublish" /> | |
| 172 | + | <Button type="submit" variant="danger"> | |
| 173 | + | Take down | |
| 174 | + | </Button> | |
| 175 | + | <span className="text-xs text-faint"> | |
| 176 | + | Published <When at={saved.published_at} time /> | |
| 177 | + | </span> | |
| 178 | + | </> | |
| 179 | + | ) : ( | |
| 180 | + | <> | |
| 181 | + | <input type="hidden" name="intent" value="publish" /> | |
| 182 | + | <Button type="submit" variant="lavender" disabled={!resolved}> | |
| 183 | + | Publish the saved draft | |
| 184 | + | </Button> | |
| 185 | + | <span className="text-xs text-faint">Needs a summary and a root cause.</span> | |
| 186 | + | </> | |
| 187 | + | )} | |
| 188 | + | </form> | |
| 189 | + | )} | |
| 190 | + | </div> | |
| 191 | + | </div> | |
| 192 | + | </main> | |
| 193 | + | ); | |
| 194 | + | } |
| 1 | + | import { CheckCircle2, Circle, CircleDot, ExternalLink, FileText, Globe, Lock, Radar } from "lucide-react"; | |
| 2 | + | import { Link, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminIncidentDetail, IncidentSeverity, IncidentStatus, TimelineEntry } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/incident"; | |
| 7 | + | import { BackLink, ImpactBadge, ImpactPicker, PhaseBadge, SeverityBadge, Timer } from "~/components/incidents"; | |
| 8 | + | import { Badge, Button, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 9 | + | import { text } from "~/lib/forms"; | |
| 10 | + | import { | |
| 11 | + | INCIDENT_SEVERITIES, | |
| 12 | + | INCIDENT_STATUSES, | |
| 13 | + | duration, | |
| 14 | + | nextStatus, | |
| 15 | + | notifyByDefault, | |
| 16 | + | readImpacts, | |
| 17 | + | secondsBetween, | |
| 18 | + | severityLabel, | |
| 19 | + | staffSuggestions, | |
| 20 | + | statusLabel, | |
| 21 | + | } from "~/lib/incidents"; | |
| 22 | + | import { staffEmails, statusAdmin } from "~/lib/services.server"; | |
| 23 | + | import { settle } from "~/lib/settle"; | |
| 24 | + | import { requireStaff } from "~/lib/staff"; | |
| 25 | + | ||
| 26 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 27 | + | { title: `${loaderData?.incident?.title ?? "Incident"} · sudo` }, | |
| 28 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 29 | + | ]; | |
| 30 | + | ||
| 31 | + | /** What the page says after a change, by the code in `?done=`. */ | |
| 32 | + | const DONE: Record<string, string> = { | |
| 33 | + | declared: "Declared and published. status.g1t.sh shows it within 30 seconds.", | |
| 34 | + | update: "Update posted. The status page shows it within 30 seconds.", | |
| 35 | + | note: "Note added. Only staff see it.", | |
| 36 | + | resolved: "Resolved. Write the postmortem while it is fresh.", | |
| 37 | + | roles: "Roles saved.", | |
| 38 | + | published: "Published to the status page.", | |
| 39 | + | dismissed: "Dismissed. It never appeared on the status page.", | |
| 40 | + | followup: "Follow-up saved.", | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | export async function loader({ params, request, context }: Route.LoaderArgs) { | |
| 44 | + | const staff = requireStaff(context); | |
| 45 | + | const [incident, components] = await Promise.all([settle(statusAdmin.incident(params.id)), settle(statusAdmin.components())]); | |
| 46 | + | if (incident.ok && !incident.value) throw new Response("No such incident.", { status: 404 }); | |
| 47 | + | const url = new URL(request.url); | |
| 48 | + | const done = url.searchParams.get("declared") ? "declared" : (url.searchParams.get("done") ?? ""); | |
| 49 | + | const detail = incident.ok ? incident.value : null; | |
| 50 | + | return { | |
| 51 | + | me: staff.email, | |
| 52 | + | incident: detail, | |
| 53 | + | components: components.ok ? components.value : [], | |
| 54 | + | error: incident.ok ? null : incident.error, | |
| 55 | + | done: DONE[done] ?? null, | |
| 56 | + | staff: staffSuggestions(staffEmails(), [staff.email, detail?.commander, detail?.communications, ...(detail?.followups.map((f) => f.owner) ?? [])]), | |
| 57 | + | now: new Date().toISOString(), | |
| 58 | + | }; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | type ActionData = { error: string; intent: string }; | |
| 62 | + | ||
| 63 | + | export async function action({ params, request, context }: Route.ActionArgs) { | |
| 64 | + | const staff = requireStaff(context); | |
| 65 | + | const form = await request.formData(); | |
| 66 | + | const intent = text(form, "intent"); | |
| 67 | + | const id = params.id ?? ""; | |
| 68 | + | const back = (done: string, anchor = "") => redirect(`/incidents/${id}?done=${done}${anchor}`); | |
| 69 | + | const failed = (error: string) => ({ error, intent }) satisfies ActionData; | |
| 70 | + | const unwrap = async <T,>(call: Promise<{ ok: true; value: T } | { ok: false; error: { message: string } }>) => { | |
| 71 | + | const result = await settle(call); | |
| 72 | + | if (!result.ok) return { ok: false as const, error: result.error }; | |
| 73 | + | return result.value.ok ? { ok: true as const, value: result.value.value } : { ok: false as const, error: result.value.error.message }; | |
| 74 | + | }; | |
| 75 | + | ||
| 76 | + | if (intent === "update" || intent === "resolve") { | |
| 77 | + | const components = await settle(statusAdmin.components()); | |
| 78 | + | const keys = components.ok ? components.value.map((c) => c.key) : []; | |
| 79 | + | const isPublic = intent === "resolve" || text(form, "visibility") === "public"; | |
| 80 | + | const status = intent === "resolve" ? "resolved" : (text(form, "status") as IncidentStatus) || null; | |
| 81 | + | const r = await unwrap( | |
| 82 | + | statusAdmin.update(id, { | |
| 83 | + | public: isPublic, | |
| 84 | + | message: String(form.get("message") ?? ""), | |
| 85 | + | status, | |
| 86 | + | severity: (text(form, "severity") as IncidentSeverity) || null, | |
| 87 | + | impacts: intent === "resolve" ? [] : readImpacts(form, keys), | |
| 88 | + | notify: isPublic && form.get("notify") === "on", | |
| 89 | + | by: staff.email, | |
| 90 | + | }), | |
| 91 | + | ); | |
| 92 | + | if (!r.ok) return failed(r.error); | |
| 93 | + | return back(status === "resolved" ? "resolved" : isPublic ? "update" : "note"); | |
| 94 | + | } | |
| 95 | + | if (intent === "roles") { | |
| 96 | + | const r = await unwrap(statusAdmin.roles(id, { commander: text(form, "commander") || null, communications: text(form, "communications") || null, by: staff.email })); | |
| 97 | + | return !r.ok ? failed(r.error) : back("roles", "#roles"); | |
| 98 | + | } | |
| 99 | + | if (intent === "publish") { | |
| 100 | + | const r = await unwrap( | |
| 101 | + | statusAdmin.publish(id, { title: text(form, "title") || null, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }), | |
| 102 | + | ); | |
| 103 | + | return !r.ok ? failed(r.error) : back("published"); | |
| 104 | + | } | |
| 105 | + | if (intent === "dismiss") { | |
| 106 | + | const r = await unwrap(statusAdmin.dismiss(id, { reason: text(form, "reason"), by: staff.email })); | |
| 107 | + | return !r.ok ? failed(r.error) : back("dismissed"); | |
| 108 | + | } | |
| 109 | + | if (intent === "followup_add") { | |
| 110 | + | const r = await unwrap(statusAdmin.addFollowUp(id, { title: text(form, "title"), owner: text(form, "owner") || null, by: staff.email })); | |
| 111 | + | return !r.ok ? failed(r.error) : back("followup", "#followups"); | |
| 112 | + | } | |
| 113 | + | if (intent === "followup_set") { | |
| 114 | + | const r = await unwrap(statusAdmin.setFollowUp(id, text(form, "followup"), { done: text(form, "done") === "1", by: staff.email })); | |
| 115 | + | return !r.ok ? failed(r.error) : back("followup", "#followups"); | |
| 116 | + | } | |
| 117 | + | return failed("Unknown action."); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | const KIND_ICON = { update: Globe, note: Lock, detected: Radar, followup: CheckCircle2, postmortem: FileText } as const; | |
| 121 | + | ||
| 122 | + | function EntryLine({ entry }: { entry: TimelineEntry }) { | |
| 123 | + | const who = entry.by === "status" ? "the checks" : entry.by; | |
| 124 | + | const meta = ( | |
| 125 | + | <span className="text-xs text-faint"> | |
| 126 | + | <When at={entry.at} time /> · <span className="font-mono">{who}</span> | |
| 127 | + | </span> | |
| 128 | + | ); | |
| 129 | + | if (entry.kind === "update") { | |
| 130 | + | return ( | |
| 131 | + | <li className="rounded-md border border-merged/30 bg-merged/6 px-3.5 py-3"> | |
| 132 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 133 | + | <Globe size={14} aria-hidden="true" className="text-merged" /> | |
| 134 | + | <span className="text-xs font-semibold text-merged">Public update</span> | |
| 135 | + | {entry.status && <Badge tone="lavender">{statusLabel(entry.status)}</Badge>} | |
| 136 | + | {meta} | |
| 137 | + | {entry.notified != null && <span className="text-xs text-faint">· emailed {entry.notified}</span>} | |
| 138 | + | </div> | |
| 139 | + | <p className="mt-1.5 text-sm whitespace-pre-line text-fg">{entry.text}</p> | |
| 140 | + | </li> | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | if (entry.kind === "note") { | |
| 144 | + | return ( | |
| 145 | + | <li className="rounded-md border border-line bg-raised/40 px-3.5 py-3"> | |
| 146 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 147 | + | <Lock size={13} aria-hidden="true" className="text-faint" /> | |
| 148 | + | <span className="text-xs font-medium text-muted">Internal note</span> | |
| 149 | + | {meta} | |
| 150 | + | </div> | |
| 151 | + | <p className="mt-1.5 text-sm whitespace-pre-line text-fg-soft">{entry.text}</p> | |
| 152 | + | </li> | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | const Icon = KIND_ICON[entry.kind as keyof typeof KIND_ICON] ?? CircleDot; | |
| 156 | + | const loud = entry.kind === "detected" || entry.kind === "failing"; | |
| 157 | + | return ( | |
| 158 | + | <li className="flex gap-2.5 px-1 py-1"> | |
| 159 | + | <Icon size={13} aria-hidden="true" className={`mt-1 shrink-0 ${loud ? "text-warn" : "text-faint"}`} /> | |
| 160 | + | <p className="min-w-0 text-sm text-muted"> | |
| 161 | + | <span className={loud ? "text-fg-soft" : ""}>{entry.text}</span> {meta} | |
| 162 | + | </p> | |
| 163 | + | </li> | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | function Header({ incident, now }: { incident: AdminIncidentDetail; now: string }) { | |
| 168 | + | const open = incident.resolved_at == null; | |
| 169 | + | const d = incident.durations; | |
| 170 | + | return ( | |
| 171 | + | <> | |
| 172 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 173 | + | <SeverityBadge severity={incident.severity} /> | |
| 174 | + | <PhaseBadge incident={incident} /> | |
| 175 | + | {incident.source === "detected" && <Badge tone="warn">Detected by the checks</Badge>} | |
| 176 | + | {incident.visibility !== "public" && <Badge>Not on the status page</Badge>} | |
| 177 | + | </div> | |
| 178 | + | <h1 className="mt-2 text-2xl font-semibold tracking-tight break-words">{incident.title}</h1> | |
| 179 | + | <p className="mt-1 text-sm text-muted"> | |
| 180 | + | Impact began <When at={incident.started_at} time /> · declared by <span className="font-mono text-xs">{incident.created_by === "status" ? "the checks" : incident.created_by}</span> | |
| 181 | + | {incident.commander && ( | |
| 182 | + | <> | |
| 183 | + | {" "}· IC <span className="font-mono text-xs">{incident.commander}</span> | |
| 184 | + | </> | |
| 185 | + | )} | |
| 186 | + | {incident.communications && ( | |
| 187 | + | <> | |
| 188 | + | {" "}· comms <span className="font-mono text-xs">{incident.communications}</span> | |
| 189 | + | </> | |
| 190 | + | )} | |
| 191 | + | </p> | |
| 192 | + | <div className="mt-4 grid grid-cols-2 divide-line rounded-lg border border-line bg-surface sm:grid-cols-4 sm:divide-x [&>*:nth-child(-n+2)]:border-b [&>*:nth-child(-n+2)]:border-line sm:[&>*:nth-child(-n+2)]:border-b-0"> | |
| 193 | + | <Timer | |
| 194 | + | label={open ? "Open for" : "Lasted"} | |
| 195 | + | value={duration(open ? secondsBetween(incident.started_at, now) : d.to_resolve)} | |
| 196 | + | tone={open ? "warn" : "mint"} | |
| 197 | + | hint={open ? `${severityLabel(incident.severity)} · ${statusLabel(incident.status)}` : <When at={incident.resolved_at} time />} | |
| 198 | + | /> | |
| 199 | + | <Timer label="To acknowledge" value={duration(d.to_acknowledge)} hint={incident.acknowledged_at ? <When at={incident.acknowledged_at} time /> : "Not yet"} /> | |
| 200 | + | <Timer label="To mitigate" value={duration(d.to_mitigate)} hint={incident.mitigated_at ? <When at={incident.mitigated_at} time /> : "At monitoring"} /> | |
| 201 | + | <Timer label="To resolve" value={duration(d.to_resolve)} hint={incident.resolved_at ? <When at={incident.resolved_at} time /> : "Still open"} /> | |
| 202 | + | </div> | |
| 203 | + | </> | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | function UpdateForm({ incident, components, error }: { incident: AdminIncidentDetail; components: { key: string; name: string }[]; error: string | null }) { | |
| 208 | + | const draft = incident.visibility === "draft"; | |
| 209 | + | const current = new Map(incident.components.map((c) => [c.key, c.impact])); | |
| 210 | + | return ( | |
| 211 | + | <Section id="update" title={draft ? "Add a note" : "Post an update"} description={draft ? "Drafts take notes and changes; publish it to post publicly." : "A public update goes on the status page; a note stays here."}> | |
| 212 | + | <form method="post" action="#update" className="space-y-4"> | |
| 213 | + | <input type="hidden" name="intent" value="update" /> | |
| 214 | + | {error && <Notice tone="error">{error}</Notice>} | |
| 215 | + | {!draft && ( | |
| 216 | + | <fieldset className="flex flex-wrap gap-2" aria-label="Who sees it"> | |
| 217 | + | <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10"> | |
| 218 | + | <input type="radio" name="visibility" value="public" defaultChecked className="accent-[#b6a8ff]" /> | |
| 219 | + | <Globe size={14} aria-hidden="true" /> Public update | |
| 220 | + | </label> | |
| 221 | + | <label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm has-checked:border-line-strong has-checked:bg-raised"> | |
| 222 | + | <input type="radio" name="visibility" value="internal" className="accent-[#b6a8ff]" /> | |
| 223 | + | <Lock size={14} aria-hidden="true" /> Internal note | |
| 224 | + | </label> | |
| 225 | + | </fieldset> | |
| 226 | + | )} | |
| 227 | + | <div className="grid gap-3 sm:grid-cols-2"> | |
| 228 | + | <Field label="Status" hint={draft ? undefined : "Changing it needs a public update."}> | |
| 229 | + | <Select name="status" defaultValue=""> | |
| 230 | + | <option value="">Keep: {statusLabel(incident.status)}</option> | |
| 231 | + | {INCIDENT_STATUSES.filter((s) => s.value !== incident.status).map((s) => ( | |
| 232 | + | <option key={s.value} value={s.value}> | |
| 233 | + | {s.label} | |
| 234 | + | {s.value === nextStatus(incident.status) ? " (next)" : ""} | |
| 235 | + | </option> | |
| 236 | + | ))} | |
| 237 | + | </Select> | |
| 238 | + | </Field> | |
| 239 | + | <Field label="Severity"> | |
| 240 | + | <Select name="severity" defaultValue=""> | |
| 241 | + | <option value="">Keep: {severityLabel(incident.severity)}</option> | |
| 242 | + | {INCIDENT_SEVERITIES.filter((s) => s.value !== incident.severity).map((s) => ( | |
| 243 | + | <option key={s.value} value={s.value}> | |
| 244 | + | {s.label} | |
| 245 | + | </option> | |
| 246 | + | ))} | |
| 247 | + | </Select> | |
| 248 | + | </Field> | |
| 249 | + | </div> | |
| 250 | + | <details className="group rounded-md"> | |
| 251 | + | <summary className="cursor-pointer text-sm text-muted select-none hover:text-fg">Change the parts' impact</summary> | |
| 252 | + | <div className="mt-2"> | |
| 253 | + | <ImpactPicker components={components} current={current} blank /> | |
| 254 | + | </div> | |
| 255 | + | </details> | |
| 256 | + | <Field label="Message"> | |
| 257 | + | <Textarea name="message" rows={4} maxLength={4000} placeholder={draft ? "What you found, for the team." : "What changed, in a sentence or two, for the people affected."} /> | |
| 258 | + | </Field> | |
| 259 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 260 | + | {!draft ? ( | |
| 261 | + | <label className="flex items-center gap-2 text-sm"> | |
| 262 | + | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> | |
| 263 | + | Email subscribers (public updates only) | |
| 264 | + | </label> | |
| 265 | + | ) : ( | |
| 266 | + | <span /> | |
| 267 | + | )} | |
| 268 | + | <Button type="submit" variant="lavender"> | |
| 269 | + | Post | |
| 270 | + | </Button> | |
| 271 | + | </div> | |
| 272 | + | </form> | |
| 273 | + | </Section> | |
| 274 | + | ); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | export default function Incident({ loaderData, actionData }: Route.ComponentProps) { | |
| 278 | + | const { incident, components, error, done, staff, now } = loaderData; | |
| 279 | + | const failed = actionData as ActionData | undefined; | |
| 280 | + | const errorFor = (intent: string) => (failed?.intent === intent ? failed.error : null); | |
| 281 | + | if (!incident) { | |
| 282 | + | return ( | |
| 283 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 284 | + | <BackLink to="/incidents">Incidents</BackLink> | |
| 285 | + | <div className="mt-4"> | |
| 286 | + | <Notice tone="warn">The status worker did not answer: {error}</Notice> | |
| 287 | + | </div> | |
| 288 | + | </main> | |
| 289 | + | ); | |
| 290 | + | } | |
| 291 | + | const names = new Map(components.map((c) => [c.key, c.name])); | |
| 292 | + | const open = incident.resolved_at == null; | |
| 293 | + | const isPublic = incident.visibility === "public"; | |
| 294 | + | const affected = incident.components.filter((c) => c.impact !== "operational"); | |
| 295 | + | const timeline = [...incident.timeline].reverse(); | |
| 296 | + | ||
| 297 | + | return ( | |
| 298 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 299 | + | <BackLink to={incident.visibility === "draft" ? "/incidents?tab=drafts" : open ? "/incidents" : "/incidents?tab=resolved"}>Incidents</BackLink> | |
| 300 | + | <Header incident={incident} now={now} /> | |
| 301 | + | ||
| 302 | + | <div className="mt-4 space-y-3"> | |
| 303 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 304 | + | {failed && !["update", "roles", "followup_add", "followup_set", "resolve", "publish", "dismiss"].includes(failed.intent) && <Notice tone="error">{failed.error}</Notice>} | |
| 305 | + | </div> | |
| 306 | + | ||
| 307 | + | {incident.visibility === "draft" && ( | |
| 308 | + | <section className="mt-5 rounded-lg border border-warn/35 bg-warn/6 p-4 sm:p-5"> | |
| 309 | + | <h2 className="font-semibold">This is a draft</h2> | |
| 310 | + | <p className="mt-1 text-sm text-muted"> | |
| 311 | + | {incident.source === "detected" | |
| 312 | + | ? "The checks failed several times in a row and made this. Nobody outside sees it until you publish it. If it was a blip, dismiss it." | |
| 313 | + | : "Nobody outside sees it until you publish it."} | |
| 314 | + | </p> | |
| 315 | + | <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 316 | + | <form method="post" className="space-y-3"> | |
| 317 | + | <input type="hidden" name="intent" value="publish" /> | |
| 318 | + | {errorFor("publish") && <Notice tone="error">{errorFor("publish")}</Notice>} | |
| 319 | + | <Field label="Public title"> | |
| 320 | + | <Input name="title" defaultValue={incident.title.replace(/^Detected: /, "")} maxLength={120} required /> | |
| 321 | + | </Field> | |
| 322 | + | <Field label="First public update"> | |
| 323 | + | <Textarea name="message" rows={3} required maxLength={4000} placeholder="We are looking into reports that…" /> | |
| 324 | + | </Field> | |
| 325 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 326 | + | <label className="flex items-center gap-2 text-sm"> | |
| 327 | + | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> | |
| 328 | + | Email subscribers | |
| 329 | + | </label> | |
| 330 | + | <Button type="submit" variant="lavender"> | |
| 331 | + | Publish to the status page | |
| 332 | + | </Button> | |
| 333 | + | </div> | |
| 334 | + | </form> | |
| 335 | + | <form method="post" className="space-y-3 lg:border-l lg:border-warn/20 lg:pl-4"> | |
| 336 | + | <input type="hidden" name="intent" value="dismiss" /> | |
| 337 | + | {errorFor("dismiss") && <Notice tone="error">{errorFor("dismiss")}</Notice>} | |
| 338 | + | <Field label="Not an incident?"> | |
| 339 | + | <Input name="reason" maxLength={500} placeholder="A blip: answered again within minutes" /> | |
| 340 | + | </Field> | |
| 341 | + | <Button type="submit" variant="quiet"> | |
| 342 | + | Dismiss draft | |
| 343 | + | </Button> | |
| 344 | + | </form> | |
| 345 | + | </div> | |
| 346 | + | </section> | |
| 347 | + | )} | |
| 348 | + | ||
| 349 | + | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 350 | + | <div className="min-w-0 space-y-6"> | |
| 351 | + | {incident.visibility !== "dismissed" && <UpdateForm incident={incident} components={components} error={errorFor("update")} />} | |
| 352 | + | <Section title="Timeline" description="Newest first. Public updates are what the status page shows; everything else is for staff."> | |
| 353 | + | <ol className="space-y-2"> | |
| 354 | + | {timeline.map((entry) => ( | |
| 355 | + | <EntryLine key={entry.id} entry={entry} /> | |
| 356 | + | ))} | |
| 357 | + | </ol> | |
| 358 | + | </Section> | |
| 359 | + | </div> | |
| 360 | + | ||
| 361 | + | <aside className="min-w-0 space-y-6"> | |
| 362 | + | {open && isPublic && ( | |
| 363 | + | <Section id="resolve" title="Resolve" description="Closes it, posts a last public update, and stops colouring the parts."> | |
| 364 | + | <form method="post" action="#resolve" className="space-y-3"> | |
| 365 | + | <input type="hidden" name="intent" value="resolve" /> | |
| 366 | + | {errorFor("resolve") && <Notice tone="error">{errorFor("resolve")}</Notice>} | |
| 367 | + | <Textarea name="message" rows={3} required maxLength={4000} aria-label="Resolution update" placeholder="Pushes work again. A bad deploy was rolled back; nothing was lost." /> | |
| 368 | + | <label className="flex items-center gap-2 text-sm"> | |
| 369 | + | <input type="checkbox" name="notify" defaultChecked={notifyByDefault(incident.severity)} className="accent-[#b6a8ff]" /> | |
| 370 | + | Email subscribers | |
| 371 | + | </label> | |
| 372 | + | <Button type="submit" variant="lavender" className="w-full"> | |
| 373 | + | <CheckCircle2 size={15} aria-hidden="true" /> Resolve incident | |
| 374 | + | </Button> | |
| 375 | + | </form> | |
| 376 | + | </Section> | |
| 377 | + | )} | |
| 378 | + | ||
| 379 | + | {!open && isPublic && ( | |
| 380 | + | <Section title="Postmortem" description={incident.postmortem_published_at ? "Published on the incident's page." : incident.postmortem ? "Drafted, not published." : "Not started."}> | |
| 381 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 382 | + | <Link to={`/incidents/${incident.id}/postmortem`} className="inline-flex items-center gap-1.5 text-sm font-medium text-merged hover:underline"> | |
| 383 | + | <FileText size={14} aria-hidden="true" /> | |
| 384 | + | {incident.postmortem ? "Edit the postmortem" : "Write the postmortem"} | |
| 385 | + | </Link> | |
| 386 | + | {incident.postmortem_published_at && <Badge tone="mint">Published</Badge>} | |
| 387 | + | </div> | |
| 388 | + | </Section> | |
| 389 | + | )} | |
| 390 | + | ||
| 391 | + | <Section title="Parts" description={open ? "What the status page shows for each while it is open." : "What it did to each."}> | |
| 392 | + | {affected.length === 0 ? ( | |
| 393 | + | <p className="text-sm text-muted">None marked.</p> | |
| 394 | + | ) : ( | |
| 395 | + | <ul className="space-y-2"> | |
| 396 | + | {affected.map((c) => ( | |
| 397 | + | <li key={c.key} className="flex items-center justify-between gap-2 text-sm"> | |
| 398 | + | <span className="min-w-0 truncate">{names.get(c.key) ?? c.key}</span> | |
| 399 | + | <ImpactBadge impact={c.impact} /> | |
| 400 | + | </li> | |
| 401 | + | ))} | |
| 402 | + | </ul> | |
| 403 | + | )} | |
| 404 | + | </Section> | |
| 405 | + | ||
| 406 | + | <Section id="roles" title="Roles"> | |
| 407 | + | <form method="post" action="#roles" className="space-y-3"> | |
| 408 | + | <input type="hidden" name="intent" value="roles" /> | |
| 409 | + | {errorFor("roles") && <Notice tone="error">{errorFor("roles")}</Notice>} | |
| 410 | + | <Field label="Incident commander"> | |
| 411 | + | <Input name="commander" type="email" list="staff" defaultValue={incident.commander ?? ""} placeholder="Nobody" /> | |
| 412 | + | </Field> | |
| 413 | + | <Field label="Communications"> | |
| 414 | + | <Input name="communications" type="email" list="staff" defaultValue={incident.communications ?? ""} placeholder="Nobody" /> | |
| 415 | + | </Field> | |
| 416 | + | <Button type="submit" variant="quiet"> | |
| 417 | + | Save roles | |
| 418 | + | </Button> | |
| 419 | + | </form> | |
| 420 | + | </Section> | |
| 421 | + | ||
| 422 | + | <Section id="followups" title="Follow-ups" description="What to change so it does not happen again."> | |
| 423 | + | {errorFor("followup_set") && ( | |
| 424 | + | <div className="mb-3"> | |
| 425 | + | <Notice tone="error">{errorFor("followup_set")}</Notice> | |
| 426 | + | </div> | |
| 427 | + | )} | |
| 428 | + | {incident.followups.length > 0 && ( | |
| 429 | + | <ul className="mb-4 space-y-1.5"> | |
| 430 | + | {incident.followups.map((f) => ( | |
| 431 | + | <li key={f.id}> | |
| 432 | + | <form method="post" action="#followups" className="flex items-start gap-2"> | |
| 433 | + | <input type="hidden" name="intent" value="followup_set" /> | |
| 434 | + | <input type="hidden" name="followup" value={f.id} /> | |
| 435 | + | <input type="hidden" name="done" value={f.done_at ? "0" : "1"} /> | |
| 436 | + | <button | |
| 437 | + | type="submit" | |
| 438 | + | aria-label={f.done_at ? `Mark “${f.title}” not done` : `Mark “${f.title}” done`} | |
| 439 | + | className="mt-0.5 shrink-0 rounded text-faint hover:text-merged" | |
| 440 | + | > | |
| 441 | + | {f.done_at ? <CheckCircle2 size={16} className="text-accent" /> : <Circle size={16} />} | |
| 442 | + | </button> | |
| 443 | + | <span className="min-w-0 text-sm"> | |
| 444 | + | <span className={f.done_at ? "text-faint line-through" : ""}>{f.title}</span> | |
| 445 | + | {f.owner && <span className="block font-mono text-xs text-faint">{f.owner}</span>} | |
| 446 | + | </span> | |
| 447 | + | </form> | |
| 448 | + | </li> | |
| 449 | + | ))} | |
| 450 | + | </ul> | |
| 451 | + | )} | |
| 452 | + | <form method="post" action="#followups" className="space-y-2"> | |
| 453 | + | <input type="hidden" name="intent" value="followup_add" /> | |
| 454 | + | {errorFor("followup_add") && <Notice tone="error">{errorFor("followup_add")}</Notice>} | |
| 455 | + | <Input name="title" required maxLength={200} placeholder="Alert on push errors above 1%" aria-label="Follow-up" /> | |
| 456 | + | <div className="flex gap-2"> | |
| 457 | + | <Input name="owner" type="email" list="staff" placeholder="Owner (optional)" aria-label="Owner" /> | |
| 458 | + | <Button type="submit" variant="quiet"> | |
| 459 | + | Add | |
| 460 | + | </Button> | |
| 461 | + | </div> | |
| 462 | + | </form> | |
| 463 | + | </Section> | |
| 464 | + | ||
| 465 | + | <Section title="Links"> | |
| 466 | + | <ul className="space-y-1.5 text-sm"> | |
| 467 | + | {isPublic ? ( | |
| 468 | + | <li> | |
| 469 | + | <a href={incident.url} className="inline-flex items-center gap-1.5 text-merged hover:underline"> | |
| 470 | + | Status page permalink <ExternalLink size={13} aria-hidden="true" /> | |
| 471 | + | </a> | |
| 472 | + | <span className="block truncate font-mono text-xs text-faint">{incident.url}</span> | |
| 473 | + | </li> | |
| 474 | + | ) : ( | |
| 475 | + | <li className="text-muted">No public page until it is published.</li> | |
| 476 | + | )} | |
| 477 | + | <li> | |
| 478 | + | <Link to="/audit" className="text-muted hover:text-fg hover:underline"> | |
| 479 | + | Audit log | |
| 480 | + | </Link> | |
| 481 | + | </li> | |
| 482 | + | </ul> | |
| 483 | + | </Section> | |
| 484 | + | <datalist id="staff"> | |
| 485 | + | {staff.map((email) => ( | |
| 486 | + | <option key={email} value={email} /> | |
| 487 | + | ))} | |
| 488 | + | </datalist> | |
| 489 | + | </aside> | |
| 490 | + | </div> | |
| 491 | + | </main> | |
| 492 | + | ); | |
| 493 | + | } |
| 1 | + | import { CalendarClock, ChevronRight, ExternalLink, Mail, Siren } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminIncident, AdminMaintenance } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/incidents"; | |
| 7 | + | import { ImpactBadge, MaintenanceBadge, PhaseBadge, SeverityBadge } from "~/components/incidents"; | |
| 8 | + | import { Button, ButtonLink, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui"; | |
| 9 | + | import { | |
| 10 | + | INCIDENT_SEVERITIES, | |
| 11 | + | TABS, | |
| 12 | + | duration, | |
| 13 | + | filterIncidents, | |
| 14 | + | incidentsHref, | |
| 15 | + | maintenanceList, | |
| 16 | + | median, | |
| 17 | + | parseFilters, | |
| 18 | + | secondsBetween, | |
| 19 | + | tabCounts, | |
| 20 | + | } from "~/lib/incidents"; | |
| 21 | + | import { statusAdmin } from "~/lib/services.server"; | |
| 22 | + | import { settle } from "~/lib/settle"; | |
| 23 | + | import { requireStaff } from "~/lib/staff"; | |
| 24 | + | ||
| 25 | + | export const meta: Route.MetaFunction = () => [{ title: "Incidents · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 26 | + | ||
| 27 | + | const STATUS_PAGE = "https://status.g1t.sh/"; | |
| 28 | + | ||
| 29 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 30 | + | requireStaff(context); | |
| 31 | + | const filters = parseFilters(new URL(request.url).searchParams); | |
| 32 | + | const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]); | |
| 33 | + | return { | |
| 34 | + | filters, | |
| 35 | + | board: board.ok ? board.value : null, | |
| 36 | + | components: components.ok ? components.value : [], | |
| 37 | + | error: board.ok ? null : board.error, | |
| 38 | + | now: new Date().toISOString(), | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | function IncidentRow({ incident, names, now }: { incident: AdminIncident; names: Map<string, string>; now: string }) { | |
| 43 | + | const affected = incident.components.filter((c) => c.impact !== "operational"); | |
| 44 | + | const open = incident.resolved_at == null; | |
| 45 | + | return ( | |
| 46 | + | <li> | |
| 47 | + | <Link | |
| 48 | + | to={`/incidents/${incident.id}`} | |
| 49 | + | className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5" | |
| 50 | + | > | |
| 51 | + | <div className="flex shrink-0 items-center gap-2 sm:w-20 sm:pt-0.5"> | |
| 52 | + | <SeverityBadge severity={incident.severity} /> | |
| 53 | + | </div> | |
| 54 | + | <div className="min-w-0 grow"> | |
| 55 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 56 | + | <p className="font-medium group-hover:text-fg">{incident.title}</p> | |
| 57 | + | <PhaseBadge incident={incident} /> | |
| 58 | + | {incident.source === "detected" && <span className="text-xs text-faint">Detected by the checks</span>} | |
| 59 | + | </div> | |
| 60 | + | <div className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 61 | + | {affected.map((c) => ( | |
| 62 | + | <span key={c.key} className="inline-flex items-center gap-1.5 text-xs text-muted"> | |
| 63 | + | {names.get(c.key) ?? c.key} <ImpactBadge impact={c.impact} /> | |
| 64 | + | </span> | |
| 65 | + | ))} | |
| 66 | + | </div> | |
| 67 | + | <p className="mt-1.5 text-xs text-faint"> | |
| 68 | + | Started <When at={incident.started_at} time /> | |
| 69 | + | {incident.commander && ( | |
| 70 | + | <> | |
| 71 | + | {" "}· IC <span className="font-mono">{incident.commander}</span> | |
| 72 | + | </> | |
| 73 | + | )} | |
| 74 | + | {incident.followups_open + incident.followups_done > 0 && ( | |
| 75 | + | <> | |
| 76 | + | {" "}· follow-ups {incident.followups_done}/{incident.followups_open + incident.followups_done} | |
| 77 | + | </> | |
| 78 | + | )} | |
| 79 | + | </p> | |
| 80 | + | </div> | |
| 81 | + | <div className="flex shrink-0 items-center gap-3 text-right sm:block"> | |
| 82 | + | <p className={`tabular text-sm font-medium ${open ? "text-warn" : "text-fg-soft"}`}> | |
| 83 | + | {open ? duration(secondsBetween(incident.started_at, now)) : duration(incident.durations.to_resolve)} | |
| 84 | + | </p> | |
| 85 | + | <p className="text-xs text-faint">{open ? "open" : incident.visibility === "dismissed" ? "dismissed" : "to resolve"}</p> | |
| 86 | + | </div> | |
| 87 | + | <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" /> | |
| 88 | + | </Link> | |
| 89 | + | </li> | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function MaintenanceRow({ m, names }: { m: AdminMaintenance; names: Map<string, string> }) { | |
| 94 | + | return ( | |
| 95 | + | <li> | |
| 96 | + | <Link | |
| 97 | + | to={`/incidents/maintenance/${m.id}`} | |
| 98 | + | className="group flex flex-col gap-2 px-4 py-3.5 hover:bg-raised/40 sm:flex-row sm:items-start sm:gap-4 sm:px-5" | |
| 99 | + | > | |
| 100 | + | <CalendarClock size={16} aria-hidden="true" className="hidden shrink-0 text-faint sm:mt-0.5 sm:block" /> | |
| 101 | + | <div className="min-w-0 grow"> | |
| 102 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 103 | + | <p className="font-medium">{m.title}</p> | |
| 104 | + | <MaintenanceBadge state={m.state} /> | |
| 105 | + | </div> | |
| 106 | + | <p className="mt-1 text-xs text-muted">{m.components.map((k) => names.get(k) ?? k).join(", ")}</p> | |
| 107 | + | <p className="mt-1 text-xs text-faint"> | |
| 108 | + | <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))} | |
| 109 | + | </p> | |
| 110 | + | </div> | |
| 111 | + | <ChevronRight size={16} aria-hidden="true" className="hidden shrink-0 self-center text-faint group-hover:text-muted sm:block" /> | |
| 112 | + | </Link> | |
| 113 | + | </li> | |
| 114 | + | ); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | export default function Incidents({ loaderData }: Route.ComponentProps) { | |
| 118 | + | const { filters, board, components, error, now } = loaderData; | |
| 119 | + | const names = new Map(components.map((c) => [c.key, c.name])); | |
| 120 | + | const incidents = board?.incidents ?? []; | |
| 121 | + | const maintenance = board?.maintenance ?? []; | |
| 122 | + | const counts = tabCounts(incidents, maintenance); | |
| 123 | + | const shown = filterIncidents(incidents, filters); | |
| 124 | + | const shownMaintenance = maintenanceList(maintenance, filters.component); | |
| 125 | + | const recent = incidents.filter((i) => i.visibility === "public" && i.resolved_at && secondsBetween(i.resolved_at, now) < 90 * 86400); | |
| 126 | + | const filtered = filters.severity != null || filters.component != null || filters.q !== ""; | |
| 127 | + | ||
| 128 | + | return ( | |
| 129 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 130 | + | <PageHeader | |
| 131 | + | title="Incidents" | |
| 132 | + | description="Declare, run and close incidents, schedule maintenance, and publish postmortems. What is public appears on status.g1t.sh within 30 seconds." | |
| 133 | + | actions={ | |
| 134 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 135 | + | <ButtonLink to="/incidents/new" variant="lavender"> | |
| 136 | + | <Siren size={15} aria-hidden="true" /> | |
| 137 | + | Declare incident | |
| 138 | + | </ButtonLink> | |
| 139 | + | <ButtonLink to="/incidents/maintenance/new" variant="quiet"> | |
| 140 | + | <CalendarClock size={15} aria-hidden="true" /> | |
| 141 | + | Schedule maintenance | |
| 142 | + | </ButtonLink> | |
| 143 | + | <a | |
| 144 | + | href={STATUS_PAGE} | |
| 145 | + | className="inline-flex items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted hover:text-fg" | |
| 146 | + | > | |
| 147 | + | status.g1t.sh <ExternalLink size={13} aria-hidden="true" /> | |
| 148 | + | </a> | |
| 149 | + | </div> | |
| 150 | + | } | |
| 151 | + | /> | |
| 152 | + | ||
| 153 | + | {error && ( | |
| 154 | + | <div className="mt-5"> | |
| 155 | + | <Notice tone="warn">The status worker did not answer: {error}</Notice> | |
| 156 | + | </div> | |
| 157 | + | )} | |
| 158 | + | ||
| 159 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 160 | + | <Stat label="Open" value={counts.open} tone={counts.open ? "danger" : "mint"} hint={counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? "" : "s"} waiting` : "No drafts waiting"} /> | |
| 161 | + | <Stat label="Maintenance ahead" value={counts.maintenance} hint="Scheduled or under way" /> | |
| 162 | + | <Stat | |
| 163 | + | label="Median time to resolve" | |
| 164 | + | value={duration(median(recent.map((i) => i.durations.to_resolve)))} | |
| 165 | + | hint={`${recent.length} public incident${recent.length === 1 ? "" : "s"}, 90 days`} | |
| 166 | + | /> | |
| 167 | + | <Stat | |
| 168 | + | label="Email subscribers" | |
| 169 | + | value={board?.subscribers ?? "—"} | |
| 170 | + | hint={board && !board.email ? "Email is off: feeds only" : "Confirmed addresses"} | |
| 171 | + | /> | |
| 172 | + | </div> | |
| 173 | + | ||
| 174 | + | <nav aria-label="Incident lists" className="mt-6 flex gap-1 overflow-x-auto border-b border-line"> | |
| 175 | + | {TABS.map((tab) => { | |
| 176 | + | const current = filters.tab === tab.value; | |
| 177 | + | return ( | |
| 178 | + | <Link | |
| 179 | + | key={tab.value} | |
| 180 | + | to={incidentsHref({ ...filters, tab: tab.value })} | |
| 181 | + | aria-current={current ? "page" : undefined} | |
| 182 | + | className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm whitespace-nowrap ${ | |
| 183 | + | current ? "border-merged font-medium text-fg" : "border-transparent text-muted hover:text-fg" | |
| 184 | + | }`} | |
| 185 | + | > | |
| 186 | + | {tab.label} | |
| 187 | + | <span className={`tabular rounded-full px-1.5 text-xs ${counts[tab.value] && tab.value !== "resolved" ? "bg-merged/15 text-merged" : "text-faint"}`}> | |
| 188 | + | {counts[tab.value]} | |
| 189 | + | </span> | |
| 190 | + | </Link> | |
| 191 | + | ); | |
| 192 | + | })} | |
| 193 | + | </nav> | |
| 194 | + | ||
| 195 | + | <form method="get" action="/incidents" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 196 | + | {filters.tab !== "open" && <input type="hidden" name="tab" value={filters.tab} />} | |
| 197 | + | <Input name="q" type="search" defaultValue={filters.q} placeholder="Search titles" aria-label="Search titles" className="sm:w-64" /> | |
| 198 | + | {filters.tab !== "maintenance" && ( | |
| 199 | + | <Select name="severity" defaultValue={filters.severity ?? ""} aria-label="Severity" className="sm:w-40"> | |
| 200 | + | <option value="">Any severity</option> | |
| 201 | + | {INCIDENT_SEVERITIES.map((s) => ( | |
| 202 | + | <option key={s.value} value={s.value}> | |
| 203 | + | {s.label} | |
| 204 | + | </option> | |
| 205 | + | ))} | |
| 206 | + | </Select> | |
| 207 | + | )} | |
| 208 | + | <Select name="component" defaultValue={filters.component ?? ""} aria-label="Part" className="sm:w-56"> | |
| 209 | + | <option value="">Any part</option> | |
| 210 | + | {components.map((c) => ( | |
| 211 | + | <option key={c.key} value={c.key}> | |
| 212 | + | {c.name} | |
| 213 | + | </option> | |
| 214 | + | ))} | |
| 215 | + | </Select> | |
| 216 | + | <div className="flex items-center gap-2"> | |
| 217 | + | <Button type="submit" variant="quiet" className="py-2"> | |
| 218 | + | Apply | |
| 219 | + | </Button> | |
| 220 | + | {filtered && ( | |
| 221 | + | <Link to={incidentsHref({ tab: filters.tab })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 222 | + | Clear | |
| 223 | + | </Link> | |
| 224 | + | )} | |
| 225 | + | </div> | |
| 226 | + | </form> | |
| 227 | + | ||
| 228 | + | <div className="mt-4"> | |
| 229 | + | {filters.tab === "maintenance" ? ( | |
| 230 | + | shownMaintenance.length === 0 ? ( | |
| 231 | + | <EmptyState title="No maintenance scheduled"> | |
| 232 | + | <Link to="/incidents/maintenance/new" className="text-merged hover:underline"> | |
| 233 | + | Schedule maintenance | |
| 234 | + | </Link>{" "} | |
| 235 | + | to tell people ahead of planned work. | |
| 236 | + | </EmptyState> | |
| 237 | + | ) : ( | |
| 238 | + | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> | |
| 239 | + | {shownMaintenance.map((m) => ( | |
| 240 | + | <MaintenanceRow key={m.id} m={m} names={names} /> | |
| 241 | + | ))} | |
| 242 | + | </ul> | |
| 243 | + | ) | |
| 244 | + | ) : shown.length === 0 ? ( | |
| 245 | + | <EmptyState | |
| 246 | + | title={ | |
| 247 | + | filtered | |
| 248 | + | ? "Nothing matches" | |
| 249 | + | : filters.tab === "open" | |
| 250 | + | ? "Nothing is open" | |
| 251 | + | : filters.tab === "drafts" | |
| 252 | + | ? "No drafts" | |
| 253 | + | : "No resolved incidents in the last 180 days" | |
| 254 | + | } | |
| 255 | + | > | |
| 256 | + | {filtered | |
| 257 | + | ? "Try another severity or part, or clear the filters." | |
| 258 | + | : filters.tab === "open" | |
| 259 | + | ? "The status page shows only its own checks." | |
| 260 | + | : filters.tab === "drafts" | |
| 261 | + | ? "When a part fails three checks in a row, a draft appears here and the alert address gets an email." | |
| 262 | + | : null} | |
| 263 | + | </EmptyState> | |
| 264 | + | ) : ( | |
| 265 | + | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> | |
| 266 | + | {shown.map((incident) => ( | |
| 267 | + | <IncidentRow key={incident.id} incident={incident} names={names} now={now} /> | |
| 268 | + | ))} | |
| 269 | + | </ul> | |
| 270 | + | )} | |
| 271 | + | </div> | |
| 272 | + | ||
| 273 | + | {board && !board.email && ( | |
| 274 | + | <p className="mt-4 flex items-center gap-2 text-xs text-faint"> | |
| 275 | + | <Mail size={13} aria-hidden="true" /> | |
| 276 | + | The status worker has no email sender or no STATUS_SECRET, so subscribers cannot sign up; the feeds still carry every update. | |
| 277 | + | </p> | |
| 278 | + | )} | |
| 279 | + | </main> | |
| 280 | + | ); | |
| 281 | + | } |
| 1 | − | import { Check, Search, Ticket, X } from "lucide-react"; | |
| 1 | + | import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react"; | |
| 2 | 2 | import { Link, data, redirect, useLocation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts"; | |
| 7 | 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui"; | |
| 8 | 8 | import { text } from "~/lib/forms"; | |
| 9 | 9 | import { | |
| 10 | − | INVITES_DONE, | |
| 11 | 10 | INVITE_TABS, | |
| 12 | 11 | type InviteTab, | |
| 12 | + | MAX_BULK, | |
| 13 | + | MAX_NOTE, | |
| 13 | 14 | TAB_LABEL, | |
| 15 | + | doneMessage, | |
| 14 | 16 | invitesHref, | |
| 15 | 17 | parseGrant, | |
| 18 | + | parseIds, | |
| 16 | 19 | parseMintEmail, | |
| 20 | + | parseNote, | |
| 17 | 21 | parseTab, | |
| 18 | 22 | parseWaitlistStatus, | |
| 19 | 23 | } from "~/lib/invites"; | |
| 32 | 36 | const name = url.searchParams.get("name")?.trim().toLowerCase() ?? ""; | |
| 33 | 37 | const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user"; | |
| 34 | 38 | const done = url.searchParams.get("done"); | |
| 39 | + | const selectAll = url.searchParams.get("select") === "all"; | |
| 35 | 40 | const [waitlist, invites, tree] = await Promise.all([ | |
| 36 | 41 | tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null, | |
| 37 | 42 | tab === "invites" ? settle(identity.invites(query || null)) : null, | |
| 47 | 52 | invites: invites?.ok ? invites.value : [], | |
| 48 | 53 | tree: tree?.ok ? tree.value : null, | |
| 49 | 54 | error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null, | |
| 50 | − | done: done ? (INVITES_DONE[done] ?? null) : null, | |
| 55 | + | done: doneMessage(done, url.searchParams.get("n")), | |
| 56 | + | selectAll, | |
| 51 | 57 | }; | |
| 52 | 58 | } | |
| 53 | 59 | ||
| 54 | 60 | export async function action({ request, context }: Route.ActionArgs) { | |
| 55 | 61 | const staff = requireStaff(context); | |
| 56 | 62 | const form = await request.formData(); | |
| 57 | − | const back = (done: string) => { | |
| 63 | + | const back = (done: string, n?: number) => { | |
| 58 | 64 | const url = new URL(request.url); | |
| 59 | 65 | url.searchParams.set("done", done); | |
| 66 | + | url.searchParams.delete("select"); | |
| 67 | + | if (n) url.searchParams.set("n", String(n)); | |
| 68 | + | else url.searchParams.delete("n"); | |
| 60 | 69 | return redirect(`${url.pathname}${url.search}`); | |
| 61 | 70 | }; | |
| 62 | 71 | switch (text(form, "intent")) { | |
| 63 | 72 | case "approve": | |
| 64 | 73 | case "dismiss": { | |
| 65 | 74 | const approve = text(form, "intent") === "approve"; | |
| 66 | − | const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email); | |
| 75 | + | const note = parseNote(text(form, "note")); | |
| 76 | + | if (!note.ok) return data({ error: note.error, id: text(form, "id") }, { status: 422 }); | |
| 77 | + | const result = await identity.decideWaitlist(text(form, "id"), approve, staff.email, approve ? note.value : null); | |
| 67 | 78 | if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 }); | |
| 68 | 79 | throw back(approve ? "approved" : "dismissed"); | |
| 69 | 80 | } | |
| 81 | + | case "bulk-approve": | |
| 82 | + | case "bulk-dismiss": { | |
| 83 | + | const approve = text(form, "intent") === "bulk-approve"; | |
| 84 | + | const ids = parseIds(form); | |
| 85 | + | if (!ids.ok) return data({ error: ids.error, section: "bulk" }, { status: 422 }); | |
| 86 | + | const note = parseNote(text(form, "note")); | |
| 87 | + | if (!note.ok) return data({ error: note.error, section: "bulk" }, { status: 422 }); | |
| 88 | + | // One at a time: each mints and emails its own invite. | |
| 89 | + | const refused: string[] = []; | |
| 90 | + | let decided = 0; | |
| 91 | + | for (const id of ids.value) { | |
| 92 | + | const result = await identity.decideWaitlist(id, approve, staff.email, approve ? note.value : null); | |
| 93 | + | if (result.ok) decided += 1; | |
| 94 | + | else refused.push(result.error.message); | |
| 95 | + | } | |
| 96 | + | if (refused.length > 0) { | |
| 97 | + | const verb = approve ? "Approved" : "Dismissed"; | |
| 98 | + | return data({ error: `${verb} ${decided}; ${refused.length} not: ${refused.join(" ")}`, section: "bulk" }, { status: 422 }); | |
| 99 | + | } | |
| 100 | + | throw back(approve ? "approved" : "dismissed", decided); | |
| 101 | + | } | |
| 70 | 102 | case "revoke": { | |
| 71 | 103 | const result = await identity.revokeInvite(text(form, "id"), staff.email); | |
| 72 | 104 | if (!result.ok) return data({ error: result.error.message, id: text(form, "id") }, { status: 422 }); | |
| 130 | 162 | ); | |
| 131 | 163 | } | |
| 132 | 164 | ||
| 133 | − | function Waitlist({ entries, query, status, actionData }: { entries: WaitlistEntry[]; query: string; status: string; actionData: ActionData }) { | |
| 165 | + | /** A note for the invite email, folded away until wanted. */ | |
| 166 | + | function NoteField({ form, label }: { form?: string; label: string }) { | |
| 167 | + | return ( | |
| 168 | + | <details className="group/note"> | |
| 169 | + | <summary className="inline-flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 170 | + | <MessageSquareText size={13} /> | |
| 171 | + | {label} | |
| 172 | + | </summary> | |
| 173 | + | <div className="mt-2"> | |
| 174 | + | <Textarea | |
| 175 | + | name="note" | |
| 176 | + | form={form} | |
| 177 | + | rows={2} | |
| 178 | + | maxLength={MAX_NOTE} | |
| 179 | + | placeholder="Optional. Goes in the invite email as a note from the g1t team." | |
| 180 | + | aria-label="Note for the invite email" | |
| 181 | + | /> | |
| 182 | + | </div> | |
| 183 | + | </details> | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** Who decided, when, what they wrote, and whether the invite was used. */ | |
| 188 | + | function Decision({ entry }: { entry: WaitlistEntry }) { | |
| 189 | + | if (!entry.decidedBy) return null; | |
| 190 | + | return ( | |
| 191 | + | <div className="space-y-1.5"> | |
| 192 | + | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint"> | |
| 193 | + | {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono text-muted">{entry.decidedBy}</span> | |
| 194 | + | <When at={entry.decidedAt} /> | |
| 195 | + | {entry.status === "invited" && | |
| 196 | + | (entry.joinedAs ? <Badge tone="mint">Joined as @{entry.joinedAs}</Badge> : <Badge>Invite not used yet</Badge>)} | |
| 197 | + | </p> | |
| 198 | + | {entry.note && ( | |
| 199 | + | <p className="text-xs text-muted"> | |
| 200 | + | Note sent: <span className="whitespace-pre-line text-fg-soft">{entry.note}</span> | |
| 201 | + | </p> | |
| 202 | + | )} | |
| 203 | + | </div> | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | function Waitlist({ | |
| 208 | + | entries, | |
| 209 | + | query, | |
| 210 | + | status, | |
| 211 | + | selectAll, | |
| 212 | + | actionData, | |
| 213 | + | }: { | |
| 214 | + | entries: WaitlistEntry[]; | |
| 215 | + | query: string; | |
| 216 | + | status: string; | |
| 217 | + | selectAll: boolean; | |
| 218 | + | actionData: ActionData; | |
| 219 | + | }) { | |
| 134 | 220 | const { pathname, search } = useLocation(); | |
| 221 | + | const waiting = entries.filter((entry) => entry.status === "waiting"); | |
| 222 | + | const params = new URLSearchParams(search); | |
| 223 | + | params.set("select", "all"); | |
| 224 | + | const selectHref = `${pathname}?${params}`; | |
| 225 | + | params.delete("select"); | |
| 226 | + | const clearHref = `${pathname}?${params}`; | |
| 227 | + | const bulkError = errorFor(actionData, { section: "bulk" }); | |
| 135 | 228 | return ( | |
| 136 | 229 | <div className="space-y-4"> | |
| 137 | 230 | <SearchForm | |
| 149 | 242 | </div> | |
| 150 | 243 | } | |
| 151 | 244 | /> | |
| 245 | + | {waiting.length > 0 && ( | |
| 246 | + | // Ticked rows belong to this form through their `form` attribute: | |
| 247 | + | // sudo runs no script, so choosing several is plain HTML. | |
| 248 | + | <form | |
| 249 | + | id="bulk" | |
| 250 | + | method="post" | |
| 251 | + | action={`${pathname}${search}`} | |
| 252 | + | className="space-y-3 rounded-lg border border-line bg-raised/40 px-4 py-3 sm:px-5" | |
| 253 | + | > | |
| 254 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 255 | + | <p className="text-sm"> | |
| 256 | + | <span className="font-medium">{waiting.length} waiting</span> | |
| 257 | + | <span className="text-muted"> · tick some, or </span> | |
| 258 | + | {selectAll ? ( | |
| 259 | + | <a href={clearHref} className="text-merged hover:underline"> | |
| 260 | + | clear | |
| 261 | + | </a> | |
| 262 | + | ) : ( | |
| 263 | + | <a href={selectHref} className="text-merged hover:underline"> | |
| 264 | + | tick all {Math.min(waiting.length, MAX_BULK)} | |
| 265 | + | </a> | |
| 266 | + | )} | |
| 267 | + | </p> | |
| 268 | + | <div className="ml-auto flex flex-wrap gap-2"> | |
| 269 | + | <Button type="submit" name="intent" value="bulk-dismiss" variant="quiet"> | |
| 270 | + | <X size={14} /> | |
| 271 | + | Dismiss ticked | |
| 272 | + | </Button> | |
| 273 | + | <Button type="submit" name="intent" value="bulk-approve" variant="lavender"> | |
| 274 | + | <Check size={14} /> | |
| 275 | + | Approve and invite ticked | |
| 276 | + | </Button> | |
| 277 | + | </div> | |
| 278 | + | </div> | |
| 279 | + | <NoteField form="bulk" label="Add a note to every invite" /> | |
| 280 | + | {bulkError && <Notice tone="error">{bulkError}</Notice>} | |
| 281 | + | </form> | |
| 282 | + | )} | |
| 152 | 283 | {entries.length === 0 ? ( | |
| 153 | − | <EmptyState title="Nobody waiting">People who ask for access on g1t.sh/register show up here, oldest first.</EmptyState> | |
| 284 | + | <EmptyState title={status === "waiting" ? "Nobody waiting" : "Nothing here"}> | |
| 285 | + | People who ask for access on g1t.sh/register show up here, newest first. | |
| 286 | + | </EmptyState> | |
| 154 | 287 | ) : ( | |
| 155 | 288 | <ul className="divide-y divide-line rounded-lg border border-line bg-surface"> | |
| 156 | − | {entries.map((entry) => { | |
| 289 | + | {entries.map((entry, index) => { | |
| 157 | 290 | const error = errorFor(actionData, { id: entry.id }); | |
| 291 | + | const open = entry.status === "waiting"; | |
| 158 | 292 | return ( | |
| 159 | − | <li key={entry.id} id={entry.id} className="scroll-mt-20 space-y-2 px-4 py-3 sm:px-5"> | |
| 160 | − | <div className="flex flex-wrap items-center gap-2"> | |
| 161 | − | <span className="font-mono text-sm break-all">{entry.email}</span> | |
| 162 | − | {entry.status === "invited" && <Badge tone="mint">Invited</Badge>} | |
| 163 | − | {entry.status === "dismissed" && <Badge>Dismissed</Badge>} | |
| 164 | − | <span className="text-xs text-faint"> | |
| 165 | − | asked <When at={entry.createdAt} /> | |
| 166 | − | {entry.updatedAt !== entry.createdAt && ( | |
| 167 | − | <> | |
| 168 | − | , again <When at={entry.updatedAt} /> | |
| 169 | − | </> | |
| 293 | + | <li key={entry.id} id={entry.id} className="scroll-mt-20 px-4 py-3 sm:px-5"> | |
| 294 | + | <div className="flex gap-3"> | |
| 295 | + | {open && ( | |
| 296 | + | <input | |
| 297 | + | type="checkbox" | |
| 298 | + | form="bulk" | |
| 299 | + | name="ids" | |
| 300 | + | value={entry.id} | |
| 301 | + | defaultChecked={selectAll && index < MAX_BULK} | |
| 302 | + | aria-label={`Tick ${entry.email}`} | |
| 303 | + | className="mt-1 size-4 shrink-0 accent-[var(--color-merged)]" | |
| 304 | + | /> | |
| 305 | + | )} | |
| 306 | + | <div className="min-w-0 grow space-y-2"> | |
| 307 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 308 | + | <span className="font-mono text-sm break-all">{entry.email}</span> | |
| 309 | + | {entry.status === "invited" && <Badge tone="mint">Invited</Badge>} | |
| 310 | + | {entry.status === "dismissed" && <Badge>Dismissed</Badge>} | |
| 311 | + | <span className="text-xs text-faint"> | |
| 312 | + | asked <When at={entry.createdAt} /> | |
| 313 | + | {entry.updatedAt !== entry.createdAt && ( | |
| 314 | + | <> | |
| 315 | + | , again <When at={entry.updatedAt} /> | |
| 316 | + | </> | |
| 317 | + | )} | |
| 318 | + | </span> | |
| 319 | + | </div> | |
| 320 | + | {entry.about ? ( | |
| 321 | + | <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote> | |
| 322 | + | ) : ( | |
| 323 | + | <p className="text-xs text-faint italic">Did not say what they will build.</p> | |
| 324 | + | )} | |
| 325 | + | <Decision entry={entry} /> | |
| 326 | + | {open && ( | |
| 327 | + | <form method="post" action={`${pathname}${search}#${entry.id}`} className="space-y-2"> | |
| 328 | + | <input type="hidden" name="id" value={entry.id} /> | |
| 329 | + | <div className="flex flex-wrap items-start justify-between gap-2"> | |
| 330 | + | <NoteField label="Add a note to the invite" /> | |
| 331 | + | <div className="ml-auto flex gap-2"> | |
| 332 | + | <Button type="submit" name="intent" value="dismiss" variant="quiet"> | |
| 333 | + | <X size={14} /> | |
| 334 | + | Dismiss | |
| 335 | + | </Button> | |
| 336 | + | <Button type="submit" name="intent" value="approve" variant="lavender"> | |
| 337 | + | <Check size={14} /> | |
| 338 | + | Approve and invite | |
| 339 | + | </Button> | |
| 340 | + | </div> | |
| 341 | + | </div> | |
| 342 | + | </form> | |
| 170 | 343 | )} | |
| 171 | − | </span> | |
| 172 | − | {entry.status === "waiting" && ( | |
| 173 | − | <form method="post" action={`${pathname}${search}#${entry.id}`} className="ml-auto flex gap-2"> | |
| 174 | − | <input type="hidden" name="id" value={entry.id} /> | |
| 175 | − | <Button type="submit" name="intent" value="dismiss" variant="quiet"> | |
| 176 | − | <X size={14} /> | |
| 177 | − | Dismiss | |
| 178 | − | </Button> | |
| 179 | − | <Button type="submit" name="intent" value="approve" variant="lavender"> | |
| 180 | − | <Check size={14} /> | |
| 181 | − | Approve and invite | |
| 182 | − | </Button> | |
| 183 | − | </form> | |
| 184 | − | )} | |
| 344 | + | {error && <Notice tone="error">{error}</Notice>} | |
| 345 | + | </div> | |
| 185 | 346 | </div> | |
| 186 | − | {entry.about && <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{entry.about}</blockquote>} | |
| 187 | − | {entry.decidedBy && ( | |
| 188 | − | <p className="text-xs text-faint"> | |
| 189 | − | {entry.status === "invited" ? "Approved" : "Dismissed"} by <span className="font-mono">{entry.decidedBy}</span>{" "} | |
| 190 | − | <When at={entry.decidedAt} /> | |
| 191 | − | </p> | |
| 192 | − | )} | |
| 193 | − | {error && <Notice tone="error">{error}</Notice>} | |
| 194 | 347 | </li> | |
| 195 | 348 | ); | |
| 196 | 349 | })} | |
| 427 | 580 | } | |
| 428 | 581 | ||
| 429 | 582 | export default function Invites({ loaderData, actionData }: Route.ComponentProps) { | |
| 430 | − | const { tab, query, status, waitlist, invites, tree, name, kind, error, done } = loaderData; | |
| 583 | + | const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData; | |
| 431 | 584 | return ( | |
| 432 | 585 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 433 | 586 | <PageHeader | |
| 453 | 606 | {error && <Notice tone="warn">Identity did not answer: {error}</Notice>} | |
| 454 | 607 | </div> | |
| 455 | 608 | <div className="mt-5"> | |
| 456 | − | {tab === "waitlist" && <Waitlist entries={waitlist} query={query} status={status} actionData={actionData} />} | |
| 609 | + | {tab === "waitlist" && ( | |
| 610 | + | <Waitlist entries={waitlist} query={query} status={status} selectAll={selectAll} actionData={actionData} /> | |
| 611 | + | )} | |
| 457 | 612 | {tab === "invites" && ( | |
| 458 | 613 | <div className="space-y-4"> | |
| 459 | 614 | <SearchForm tab="invites" query={query} placeholder="A code's start (g1t-k7m2), an email, or a username" /> |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/maintenance-new"; | |
| 4 | + | import { BackLink } from "~/components/incidents"; | |
| 5 | + | import { Button, Field, Input, Notice, PageHeader, Section, Textarea } from "~/components/ui"; | |
| 6 | + | import { text } from "~/lib/forms"; | |
| 7 | + | import { localValue, utc } from "~/lib/incidents"; | |
| 8 | + | import { statusAdmin } from "~/lib/services.server"; | |
| 9 | + | import { settle } from "~/lib/settle"; | |
| 10 | + | import { requireStaff } from "~/lib/staff"; | |
| 11 | + | ||
| 12 | + | export const meta: Route.MetaFunction = () => [{ title: "Schedule maintenance · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 13 | + | ||
| 14 | + | export async function loader({ context }: Route.LoaderArgs) { | |
| 15 | + | requireStaff(context); | |
| 16 | + | const components = await settle(statusAdmin.components()); | |
| 17 | + | // A sensible default: tomorrow, 02:00 to 03:00 UTC. | |
| 18 | + | const now = new Date(); | |
| 19 | + | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 2)); | |
| 20 | + | return { | |
| 21 | + | components: components.ok ? components.value : [], | |
| 22 | + | error: components.ok ? null : components.error, | |
| 23 | + | start: localValue(start), | |
| 24 | + | end: localValue(new Date(start.getTime() + 3600_000)), | |
| 25 | + | }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type ActionData = { error: string; values: Record<string, string>; chosen: string[] }; | |
| 29 | + | ||
| 30 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 31 | + | const staff = requireStaff(context); | |
| 32 | + | const form = await request.formData(); | |
| 33 | + | const values = Object.fromEntries(["title", "message", "starts_at", "ends_at"].map((k) => [k, text(form, k)])); | |
| 34 | + | const chosen = form.getAll("components").map(String); | |
| 35 | + | const result = await settle( | |
| 36 | + | statusAdmin.scheduleMaintenance({ | |
| 37 | + | title: text(form, "title"), | |
| 38 | + | message: String(form.get("message") ?? ""), | |
| 39 | + | components: chosen, | |
| 40 | + | starts_at: utc(text(form, "starts_at")) ?? "", | |
| 41 | + | ends_at: utc(text(form, "ends_at")) ?? "", | |
| 42 | + | notify: form.get("notify") === "on", | |
| 43 | + | by: staff.email, | |
| 44 | + | }), | |
| 45 | + | ); | |
| 46 | + | if (!result.ok) return { error: result.error, values, chosen } satisfies ActionData; | |
| 47 | + | if (!result.value.ok) return { error: result.value.error.message, values, chosen } satisfies ActionData; | |
| 48 | + | return redirect(`/incidents/maintenance/${result.value.value.id}?done=scheduled`); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export default function ScheduleMaintenance({ loaderData, actionData }: Route.ComponentProps) { | |
| 52 | + | const { components, error, start, end } = loaderData; | |
| 53 | + | const failed = actionData as ActionData | undefined; | |
| 54 | + | const v = failed?.values ?? {}; | |
| 55 | + | return ( | |
| 56 | + | <main className="mx-auto max-w-3xl px-4 py-8 sm:py-10"> | |
| 57 | + | <BackLink to="/incidents?tab=maintenance">Incidents</BackLink> | |
| 58 | + | <div className="mt-3"> | |
| 59 | + | <PageHeader | |
| 60 | + | title="Schedule maintenance" | |
| 61 | + | description="Shown on status.g1t.sh as upcoming maintenance at once; during the window its parts show “Under maintenance” and their checks do not count against uptime. It completes on its own when the window ends." | |
| 62 | + | /> | |
| 63 | + | </div> | |
| 64 | + | <div className="mt-5 space-y-3"> | |
| 65 | + | {error && <Notice tone="warn">The status worker did not answer: {error}</Notice>} | |
| 66 | + | {failed && <Notice tone="error">{failed.error}</Notice>} | |
| 67 | + | </div> | |
| 68 | + | <form method="post" className="mt-6 space-y-6"> | |
| 69 | + | <Section title="What and when"> | |
| 70 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 71 | + | <Field label="Title" className="sm:col-span-2"> | |
| 72 | + | <Input name="title" required maxLength={120} autoFocus defaultValue={v.title} placeholder="Database upgrade" /> | |
| 73 | + | </Field> | |
| 74 | + | <Field label="Starts (UTC)"> | |
| 75 | + | <Input type="datetime-local" name="starts_at" required defaultValue={v.starts_at || start} /> | |
| 76 | + | </Field> | |
| 77 | + | <Field label="Ends (UTC)" hint="Up to 72 hours after it starts."> | |
| 78 | + | <Input type="datetime-local" name="ends_at" required defaultValue={v.ends_at || end} /> | |
| 79 | + | </Field> | |
| 80 | + | </div> | |
| 81 | + | </Section> | |
| 82 | + | <Section title="Parts affected"> | |
| 83 | + | <div className="flex flex-wrap gap-2"> | |
| 84 | + | {components.map((c) => ( | |
| 85 | + | <label | |
| 86 | + | key={c.key} | |
| 87 | + | className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-line px-2.5 py-1.5 text-sm has-checked:border-merged/60 has-checked:bg-merged/10" | |
| 88 | + | > | |
| 89 | + | <input type="checkbox" name="components" value={c.key} defaultChecked={failed?.chosen.includes(c.key)} className="accent-[#b6a8ff]" /> | |
| 90 | + | {c.name} | |
| 91 | + | </label> | |
| 92 | + | ))} | |
| 93 | + | </div> | |
| 94 | + | </Section> | |
| 95 | + | <Section title="Message" description="What will happen and what people will notice. Shown publicly."> | |
| 96 | + | <Textarea name="message" rows={4} required maxLength={4000} aria-label="Message" defaultValue={v.message} placeholder="We are upgrading the database behind git. Pushes may pause for up to five minutes; clones and the website keep working." /> | |
| 97 | + | <label className="mt-3 flex items-center gap-2 text-sm"> | |
| 98 | + | <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" /> | |
| 99 | + | Email subscribers now, when it starts, and when it ends | |
| 100 | + | </label> | |
| 101 | + | </Section> | |
| 102 | + | <Button type="submit" variant="lavender"> | |
| 103 | + | Schedule | |
| 104 | + | </Button> | |
| 105 | + | </form> | |
| 106 | + | </main> | |
| 107 | + | ); | |
| 108 | + | } |
| 1 | + | import { ExternalLink } from "lucide-react"; | |
| 2 | + | import { redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { MaintenanceChange } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/maintenance"; | |
| 7 | + | import { BackLink, MaintenanceBadge } from "~/components/incidents"; | |
| 8 | + | import { Button, EmptyState, Field, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; | |
| 9 | + | import { text } from "~/lib/forms"; | |
| 10 | + | import { duration, secondsBetween } from "~/lib/incidents"; | |
| 11 | + | import { statusAdmin } from "~/lib/services.server"; | |
| 12 | + | import { settle } from "~/lib/settle"; | |
| 13 | + | import { requireStaff } from "~/lib/staff"; | |
| 14 | + | ||
| 15 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 16 | + | { title: `${loaderData?.maintenance?.title ?? "Maintenance"} · sudo` }, | |
| 17 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 18 | + | ]; | |
| 19 | + | ||
| 20 | + | const DONE: Record<string, string> = { | |
| 21 | + | scheduled: "Scheduled. status.g1t.sh lists it as upcoming within 30 seconds.", | |
| 22 | + | update: "Update posted.", | |
| 23 | + | start: "Started early.", | |
| 24 | + | complete: "Marked complete.", | |
| 25 | + | cancel: "Cancelled.", | |
| 26 | + | }; | |
| 27 | + | ||
| 28 | + | export async function loader({ params, request, context }: Route.LoaderArgs) { | |
| 29 | + | requireStaff(context); | |
| 30 | + | const [board, components] = await Promise.all([settle(statusAdmin.board()), settle(statusAdmin.components())]); | |
| 31 | + | const maintenance = board.ok ? (board.value.maintenance.find((m) => m.id === params.id) ?? null) : null; | |
| 32 | + | if (board.ok && !maintenance) throw new Response("No such maintenance, or it ended over 90 days ago.", { status: 404 }); | |
| 33 | + | return { | |
| 34 | + | maintenance, | |
| 35 | + | names: components.ok ? components.value : [], | |
| 36 | + | error: board.ok ? null : board.error, | |
| 37 | + | done: DONE[new URL(request.url).searchParams.get("done") ?? ""] ?? null, | |
| 38 | + | }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export async function action({ params, request, context }: Route.ActionArgs) { | |
| 42 | + | const staff = requireStaff(context); | |
| 43 | + | const form = await request.formData(); | |
| 44 | + | const act = text(form, "intent") as MaintenanceChange["action"]; | |
| 45 | + | const result = await settle( | |
| 46 | + | statusAdmin.changeMaintenance(params.id, { action: act, message: String(form.get("message") ?? ""), notify: form.get("notify") === "on", by: staff.email }), | |
| 47 | + | ); | |
| 48 | + | if (!result.ok) return { error: result.error }; | |
| 49 | + | if (!result.value.ok) return { error: result.value.error.message }; | |
| 50 | + | return redirect(`/incidents/maintenance/${params.id}?done=${act}`); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | export default function Maintenance({ loaderData, actionData }: Route.ComponentProps) { | |
| 54 | + | const { maintenance: m, names, error, done } = loaderData; | |
| 55 | + | const failed = actionData as { error: string } | undefined; | |
| 56 | + | if (!m) { | |
| 57 | + | return ( | |
| 58 | + | <main className="mx-auto max-w-4xl px-4 py-8"> | |
| 59 | + | <Notice tone="warn">The status worker did not answer: {error}</Notice> | |
| 60 | + | </main> | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | const name = new Map(names.map((c) => [c.key, c.name])); | |
| 64 | + | const live = m.state === "scheduled" || m.state === "in_progress"; | |
| 65 | + | return ( | |
| 66 | + | <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10"> | |
| 67 | + | <BackLink to="/incidents?tab=maintenance">Incidents</BackLink> | |
| 68 | + | <div className="mt-3"> | |
| 69 | + | <PageHeader | |
| 70 | + | title={m.title} | |
| 71 | + | description={ | |
| 72 | + | <> | |
| 73 | + | <MaintenanceBadge state={m.state} /> <When at={m.starts_at} time /> to <When at={m.ends_at} time /> · {duration(secondsBetween(m.starts_at, m.ends_at))} ·{" "} | |
| 74 | + | {m.components.map((k) => name.get(k) ?? k).join(", ")} | |
| 75 | + | </> | |
| 76 | + | } | |
| 77 | + | actions={ | |
| 78 | + | <a href={m.url} className="inline-flex items-center gap-1.5 text-sm text-merged hover:underline"> | |
| 79 | + | On the status page <ExternalLink size={13} aria-hidden="true" /> | |
| 80 | + | </a> | |
| 81 | + | } | |
| 82 | + | /> | |
| 83 | + | </div> | |
| 84 | + | <div className="mt-4 space-y-3"> | |
| 85 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 86 | + | {failed && <Notice tone="error">{failed.error}</Notice>} | |
| 87 | + | </div> | |
| 88 | + | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 89 | + | <div className="min-w-0 space-y-6"> | |
| 90 | + | {live && ( | |
| 91 | + | <Section title="Post an update" description="Shown on its page and the status page."> | |
| 92 | + | <form method="post" className="space-y-3"> | |
| 93 | + | <Textarea name="message" rows={3} maxLength={4000} aria-label="Update" placeholder="Running a little long: about 20 more minutes." /> | |
| 94 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 95 | + | <label className="flex items-center gap-2 text-sm"> | |
| 96 | + | <input type="checkbox" name="notify" className="accent-[#b6a8ff]" /> | |
| 97 | + | Email subscribers | |
| 98 | + | </label> | |
| 99 | + | <Button type="submit" name="intent" value="update" variant="lavender"> | |
| 100 | + | Post update | |
| 101 | + | </Button> | |
| 102 | + | </div> | |
| 103 | + | </form> | |
| 104 | + | </Section> | |
| 105 | + | )} | |
| 106 | + | <Section title="Updates" description="Newest first, as the status page shows them."> | |
| 107 | + | {m.updates.length === 0 ? ( | |
| 108 | + | <EmptyState title="No updates" /> | |
| 109 | + | ) : ( | |
| 110 | + | <ol className="space-y-3"> | |
| 111 | + | {m.updates.map((u) => ( | |
| 112 | + | <li key={u.id} className="rounded-md border border-line bg-raised/30 px-3.5 py-3"> | |
| 113 | + | <p className="text-xs text-faint"> | |
| 114 | + | <When at={u.at} time /> | |
| 115 | + | </p> | |
| 116 | + | <p className="mt-1 text-sm whitespace-pre-line text-fg-soft">{u.text}</p> | |
| 117 | + | </li> | |
| 118 | + | ))} | |
| 119 | + | </ol> | |
| 120 | + | )} | |
| 121 | + | </Section> | |
| 122 | + | </div> | |
| 123 | + | <aside className="min-w-0 space-y-6"> | |
| 124 | + | {live ? ( | |
| 125 | + | <Section title="Move it along" description="It starts and completes on its own with its window; do it by hand when the work runs early or late."> | |
| 126 | + | <form method="post" className="space-y-3"> | |
| 127 | + | <Field label="Message (optional)"> | |
| 128 | + | <Textarea name="message" rows={2} maxLength={4000} placeholder="Said for you when empty." /> | |
| 129 | + | </Field> | |
| 130 | + | <label className="flex items-center gap-2 text-sm"> | |
| 131 | + | <input type="checkbox" name="notify" defaultChecked className="accent-[#b6a8ff]" /> | |
| 132 | + | Email subscribers | |
| 133 | + | </label> | |
| 134 | + | <div className="flex flex-wrap gap-2"> | |
| 135 | + | {m.state === "scheduled" && ( | |
| 136 | + | <Button type="submit" name="intent" value="start" variant="quiet"> | |
| 137 | + | Start now | |
| 138 | + | </Button> | |
| 139 | + | )} | |
| 140 | + | <Button type="submit" name="intent" value="complete" variant="quiet"> | |
| 141 | + | Complete | |
| 142 | + | </Button> | |
| 143 | + | <Button type="submit" name="intent" value="cancel" variant="danger"> | |
| 144 | + | Cancel | |
| 145 | + | </Button> | |
| 146 | + | </div> | |
| 147 | + | </form> | |
| 148 | + | </Section> | |
| 149 | + | ) : ( | |
| 150 | + | <Section title="Finished"> | |
| 151 | + | <p className="text-sm text-muted">This maintenance is {m.state === "completed" ? "complete" : "cancelled"}. It stays in the status page's history.</p> | |
| 152 | + | </Section> | |
| 153 | + | )} | |
| 154 | + | <Section title="Scheduled by"> | |
| 155 | + | <p className="font-mono text-xs text-fg-soft">{m.created_by}</p> | |
| 156 | + | <p className="mt-1 text-xs text-faint"> | |
| 157 | + | <When at={m.created_at} time /> | |
| 158 | + | </p> | |
| 159 | + | </Section> | |
| 160 | + | </aside> | |
| 161 | + | </div> | |
| 162 | + | </main> | |
| 163 | + | ); | |
| 164 | + | } |
| 529 | 529 | ["Held by reservations", usd(e.heldMicros ?? 0)], | |
| 530 | 530 | ["Prepaid", usd(e.prepaidMicros ?? 0)], | |
| 531 | 531 | ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"], | |
| 532 | − | ["Private storage", `${gigabytes(e.privateStorageBytes)} of ${gigabytes(e.freePrivateStorageBytes)}`], | |
| 532 | + | ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`], | |
| 533 | 533 | ["Open-source pool paid", usd(e.ossPaidMicros)], | |
| 534 | − | ["Build minutes", `${Math.ceil(e.buildSecondsUsed / 60)} of ${Math.floor(e.buildSecondsIncluded / 60)}`], | |
| 534 | + | ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`], | |
| 535 | 535 | [ | |
| 536 | 536 | "Git operations", | |
| 537 | 537 | e.gitOperations != null | |
| 538 | − | ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` of ${e.gitOperationsIncluded.toLocaleString("en-US")}` : ""}` | |
| 538 | + | ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}` | |
| 539 | 539 | : "—", | |
| 540 | 540 | ], | |
| 541 | 541 | ["Audit log", `${e.auditRetentionDays} days`], |
| 6 | 6 | BILLING: ServiceBinding; | |
| 7 | 7 | IDENTITY: ServiceBinding; | |
| 8 | 8 | EVENTS: ServiceBinding; | |
| 9 | + | /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */ | |
| 10 | + | STATUS: Fetcher; | |
| 9 | 11 | ASSETS: Fetcher; | |
| 10 | 12 | ACCESS_TEAM_DOMAIN: string; | |
| 11 | 13 | ACCESS_AUD: string; |
| 19 | 19 | "services": [ | |
| 20 | 20 | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 21 | 21 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 22 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 22 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 23 | + | // The status page's staff-only entrypoint: posting incidents to | |
| 24 | + | // status.g1t.sh (apps/status). Only bindings reach it. | |
| 25 | + | { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" } | |
| 23 | 26 | ], | |
| 24 | 27 | "vars": { | |
| 25 | 28 | // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. |
| 1 | + | import { Form } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { Button } from "./ui"; | |
| 4 | + | ||
| 5 | + | /** One row's way out of a list in your settings: a key, a token, an application. */ | |
| 6 | + | export function DeleteButton({ | |
| 7 | + | intent, | |
| 8 | + | id, | |
| 9 | + | label = "Delete", | |
| 10 | + | }: { | |
| 11 | + | intent: string; | |
| 12 | + | id: string; | |
| 13 | + | label?: string; | |
| 14 | + | }) { | |
| 15 | + | return ( | |
| 16 | + | <Form method="post" className="ml-auto"> | |
| 17 | + | <input type="hidden" name="intent" value={intent} /> | |
| 18 | + | <input type="hidden" name="id" value={id} /> | |
| 19 | + | <Button variant="quiet" type="submit"> | |
| 20 | + | {label} | |
| 21 | + | </Button> | |
| 22 | + | </Form> | |
| 23 | + | ); | |
| 24 | + | } |
| 8 | 8 | import type { ReactNode } from "react"; | |
| 9 | 9 | import { Form } from "react-router"; | |
| 10 | 10 | ||
| 11 | − | import type { DeployUsage, Entitlements, FeatureState, Limit, LimitRequest, UsageAlert } from "@g1t/contracts"; | |
| 12 | − | import { DEPLOYMENTS_ALLOWANCE } from "@g1t/contracts"; | |
| 11 | + | import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts"; | |
| 13 | 12 | ||
| 14 | 13 | import { | |
| 15 | 14 | CAPS, | |
| 18 | 17 | alertText, | |
| 19 | 18 | alertTone, | |
| 20 | 19 | dollars, | |
| 20 | + | gigabytes, | |
| 21 | 21 | requestStatus, | |
| 22 | 22 | share, | |
| 23 | + | shownMeters, | |
| 23 | 24 | spendRange, | |
| 24 | 25 | wholeDollars, | |
| 25 | 26 | } from "../lib/billing"; | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | /** One meter: how much of something is used. */ | |
| 101 | − | export function Meter({ used, of, label, detail }: { used: number; of: number | null; label: string; detail?: ReactNode }) { | |
| 102 | + | export function Meter({ | |
| 103 | + | used, | |
| 104 | + | of, | |
| 105 | + | label, | |
| 106 | + | detail, | |
| 107 | + | calm = false, | |
| 108 | + | }: { | |
| 109 | + | used: number; | |
| 110 | + | of: number | null; | |
| 111 | + | label: string; | |
| 112 | + | detail?: ReactNode; | |
| 113 | + | /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */ | |
| 114 | + | calm?: boolean; | |
| 115 | + | }) { | |
| 102 | 116 | const part = share(used, of); | |
| 103 | − | const bar = of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent"; | |
| 117 | + | const bar = calm ? "bg-accent" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent"; | |
| 104 | 118 | return ( | |
| 105 | 119 | <div className="mt-4"> | |
| 106 | 120 | <div className="flex flex-wrap justify-between gap-x-4 text-sm"> | |
| 229 | 243 | enabled, | |
| 230 | 244 | live, | |
| 231 | 245 | busy, | |
| 232 | − | deployUsage, | |
| 246 | + | meters, | |
| 233 | 247 | error, | |
| 234 | 248 | }: { | |
| 235 | 249 | state: FeatureState | null; | |
| 239 | 253 | enabled: boolean; | |
| 240 | 254 | live: boolean; | |
| 241 | 255 | busy: boolean; | |
| 242 | − | deployUsage: DeployUsage | null; | |
| 256 | + | /** This month's usage by meter, from billing's `usage_meters`. */ | |
| 257 | + | meters: MeterUsage[] | null; | |
| 243 | 258 | error?: string; | |
| 244 | 259 | }) { | |
| 245 | 260 | const plan = state?.plan; | |
| 297 | 312 | label="Included usage this month" | |
| 298 | 313 | used={entitlements.includedUsedMicros ?? 0} | |
| 299 | 314 | of={entitlements.includedMicros} | |
| 300 | − | detail="Used first, at cost plus 20%. It starts again on the 1st; what is unused does not carry over." | |
| 315 | + | calm | |
| 316 | + | detail="Used first, at cost plus 20%. Past it, usage goes on at the same prices. It starts again on the 1st; what is unused does not carry over." | |
| 301 | 317 | /> | |
| 302 | 318 | ) : null} | |
| 303 | − | {on && deployUsage && <DeployMeter usage={deployUsage} entitlements={entitlements} />} | |
| 319 | + | {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />} | |
| 304 | 320 | ||
| 305 | 321 | {status.kind === "comped" || status.kind === "enterprise" ? ( | |
| 306 | 322 | <p className="mt-4 text-sm text-muted"> | |
| 352 | 368 | ); | |
| 353 | 369 | } | |
| 354 | 370 | ||
| 355 | − | /** This month's deployments against what the plan includes. */ | |
| 356 | − | function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) { | |
| 357 | − | const a = DEPLOYMENTS_ALLOWANCE; | |
| 358 | − | const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60; | |
| 359 | − | const rows: [string, number, number][] = [ | |
| 360 | − | ["Apps up at once (most this month)", usage.peakApps, a.apps], | |
| 361 | − | ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes], | |
| 362 | − | ["Requests", usage.requests, a.requests], | |
| 363 | − | ["CPU milliseconds", usage.cpuMs, a.cpuMs], | |
| 364 | − | ]; | |
| 371 | + | /** | |
| 372 | + | * This month's usage, one line per meter, in dollars at cost plus 20% and | |
| 373 | + | * in what was used. No quotas: every line is metered from the first unit. | |
| 374 | + | */ | |
| 375 | + | function MonthUsage({ | |
| 376 | + | meters, | |
| 377 | + | on, | |
| 378 | + | comped, | |
| 379 | + | entitlements, | |
| 380 | + | }: { | |
| 381 | + | meters: MeterUsage[]; | |
| 382 | + | on: boolean; | |
| 383 | + | comped: boolean; | |
| 384 | + | entitlements: Entitlements | null; | |
| 385 | + | }) { | |
| 386 | + | const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000); | |
| 387 | + | const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US"); | |
| 388 | + | const rows = shownMeters(meters, on); | |
| 389 | + | const total = rows.reduce((sum, row) => sum + row.micros, 0); | |
| 365 | 390 | return ( | |
| 366 | 391 | <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4"> | |
| 367 | − | <p className="text-xs font-medium text-muted">Deployments this month ({usage.month})</p> | |
| 368 | − | <ul className="mt-2 space-y-2.5"> | |
| 369 | − | {rows.map(([label, used, included]) => ( | |
| 370 | − | <li key={label} className="text-sm"> | |
| 371 | − | <div className="flex justify-between gap-4"> | |
| 372 | − | <span className="text-muted">{label}</span> | |
| 373 | − | <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}> | |
| 374 | − | {used.toLocaleString("en-US")} <span className="text-faint">of {included.toLocaleString("en-US")}</span> | |
| 375 | − | </span> | |
| 376 | − | </div> | |
| 377 | − | <div className="mt-1 h-1 overflow-hidden rounded-full bg-line"> | |
| 378 | − | <div className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`} style={{ width: `${share(used, included) * 100}%` }} /> | |
| 379 | − | </div> | |
| 392 | + | <div className="flex items-baseline justify-between gap-4"> | |
| 393 | + | <p className="text-xs font-medium text-muted">This month's usage</p> | |
| 394 | + | <p className="text-xs text-faint">At cost plus 20%</p> | |
| 395 | + | </div> | |
| 396 | + | <ul className="mt-2 divide-y divide-line/60"> | |
| 397 | + | {rows.map((row) => ( | |
| 398 | + | <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm"> | |
| 399 | + | <span className="min-w-0"> | |
| 400 | + | <span>{row.label}</span> | |
| 401 | + | {(row.quantity || row.micros === 0) && ( | |
| 402 | + | <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span> | |
| 403 | + | )} | |
| 404 | + | </span> | |
| 405 | + | <span className={`shrink-0 tabular-nums ${row.micros > 0 ? "" : "text-faint"}`}>{dollars(row.micros)}</span> | |
| 380 | 406 | </li> | |
| 381 | 407 | ))} | |
| 382 | 408 | </ul> | |
| 409 | + | <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm"> | |
| 410 | + | <span className="font-medium">Total</span> | |
| 411 | + | <span className="font-medium tabular-nums">{dollars(total)}</span> | |
| 412 | + | </div> | |
| 413 | + | <p className="mt-2 text-xs text-faint"> | |
| 414 | + | {comped | |
| 415 | + | ? "What this workspace's usage would cost. g1t covers it." | |
| 416 | + | : on | |
| 417 | + | ? `Drawn from the included usage first, then charged up to your spend limit. Projects, previews and repositories are never charged, and the first ${freeStorage} of private storage and ${freeGit} git operations a month are free. App traffic, custom domains, storage and git operations are counted through the month and charged when it closes.` | |
| 418 | + | : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial or the open-source pool.`} | |
| 419 | + | </p> | |
| 383 | 420 | </div> | |
| 384 | 421 | ); | |
| 385 | 422 | } |
| 17 | 17 | <CopyLine text={`https://g1t.sh/${path}.git`} /> | |
| 18 | 18 | <p className="mt-2 text-xs text-muted"> | |
| 19 | 19 | To push, use your username and an{" "} | |
| 20 | − | <Link to="/settings" className="text-fg underline underline-offset-4"> | |
| 20 | + | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> | |
| 21 | 21 | access token | |
| 22 | 22 | </Link>{" "} | |
| 23 | 23 | as the password. | |
| 26 | 26 | <TabsContent value="ssh"> | |
| 27 | 27 | <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted"> | |
| 28 | 28 | Git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for now: it | |
| 29 | − | clones, fetches and pushes the same. Keys you add under Settings will work as soon as SSH is on. | |
| 29 | + | clones, fetches and pushes the same. Keys you add under Settings → SSH keys will work as soon as SSH is on. | |
| 30 | 30 | </p> | |
| 31 | 31 | </TabsContent> | |
| 32 | 32 | <TabsContent value="agent"> |
| 94 | 94 | const full = data.emails.length >= data.limit; | |
| 95 | 95 | return ( | |
| 96 | 96 | <section id="emails" className="scroll-mt-20"> | |
| 97 | − | <h2 className="font-medium">Emails</h2> | |
| 98 | − | <p className="mt-1 text-sm text-muted"> | |
| 99 | − | Your primary address gets account mail and password resets. Any confirmed address signs you in and can | |
| 100 | − | reset your password, and commits that carry it are shown as yours. | |
| 101 | − | </p> | |
| 102 | − | ||
| 103 | 97 | {actionData?.reauth && ( | |
| 104 | − | <div className="mt-4"> | |
| 98 | + | <div className="mb-4"> | |
| 105 | 99 | <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} /> | |
| 106 | 100 | </div> | |
| 107 | 101 | )} | |
| 108 | 102 | ||
| 109 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 103 | + | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 110 | 104 | {data.emails.map((email) => { | |
| 111 | 105 | const can = addressActions(email, data.emails); | |
| 112 | 106 | return ( | |
| 162 | 156 | )} | |
| 163 | 157 | ||
| 164 | 158 | <div className="mt-8"> | |
| 165 | − | <h3 className="text-sm font-medium">Backup address</h3> | |
| 159 | + | <h2 className="font-medium">Backup address</h2> | |
| 166 | 160 | <p className="mt-1 text-sm text-muted"> | |
| 167 | 161 | Security notices, such as a new address or a changed password, go to your primary and to this address. | |
| 168 | 162 | </p> | |
| 194 | 188 | </div> | |
| 195 | 189 | ||
| 196 | 190 | <div className="mt-8"> | |
| 197 | − | <h3 className="text-sm font-medium">Privacy</h3> | |
| 191 | + | <h2 className="font-medium">Privacy</h2> | |
| 198 | 192 | <Form method="post" className="mt-3 space-y-3"> | |
| 199 | 193 | <input type="hidden" name="intent" value="email-privacy" /> | |
| 200 | 194 | <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private"> | |
| 223 | 217 | export function SecurityLogSection({ log }: { log: SecurityEvent[] }) { | |
| 224 | 218 | return ( | |
| 225 | 219 | <section id="security-log" className="scroll-mt-20"> | |
| 226 | − | <h2 className="font-medium">Security log</h2> | |
| 227 | − | <p className="mt-1 text-sm text-muted"> | |
| 228 | − | Changes to your addresses and password, by you or by g1t staff. If you do not recognise one, reset your | |
| 229 | − | password. | |
| 230 | − | </p> | |
| 231 | 220 | {log.length === 0 ? ( | |
| 232 | − | <p className="mt-4 text-sm text-faint">Nothing yet.</p> | |
| 221 | + | <p className="text-sm text-faint">Nothing yet.</p> | |
| 233 | 222 | ) : ( | |
| 234 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 223 | + | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 235 | 224 | {log.map((event, at) => ( | |
| 236 | 225 | <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"> | |
| 237 | 226 | <p className="min-w-0 grow truncate text-sm"> |
| 1 | + | /** | |
| 2 | + | * The fields a bot fills in and a person never sees, on the forms anyone | |
| 3 | + | * can send: signing up and asking for access. `looksAutomated` in | |
| 4 | + | * lib/invites.ts reads them. | |
| 5 | + | */ | |
| 6 | + | export function Honeypot({ started }: { started: number }) { | |
| 7 | + | return ( | |
| 8 | + | <> | |
| 9 | + | <input type="hidden" name="started" value={started} /> | |
| 10 | + | <div aria-hidden="true" className="absolute left-[-9999px] h-px w-px overflow-hidden"> | |
| 11 | + | <label> | |
| 12 | + | Website | |
| 13 | + | <input type="text" name="website" tabIndex={-1} autoComplete="off" /> | |
| 14 | + | </label> | |
| 15 | + | </div> | |
| 16 | + | </> | |
| 17 | + | ); | |
| 18 | + | } |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | /** | |
| 47 | − | * Account settings → Invites: what is left, making one (for anyone with the | |
| 47 | + | * Settings → Invites (`/settings/invites`): what is left, making one (for anyone with the | |
| 48 | 48 | * link, or for one address), copying links, and revoking. | |
| 49 | 49 | */ | |
| 50 | 50 | export function InvitesSection({ | |
| 61 | 61 | if (!overview) { | |
| 62 | 62 | return ( | |
| 63 | 63 | <section id="invites" className="scroll-mt-20"> | |
| 64 | − | <h2 className="font-medium">Invites</h2> | |
| 65 | − | <p className="mt-1 text-sm text-muted">Confirm your email address to invite people to g1t.</p> | |
| 64 | + | <p className="text-sm text-muted">Confirm your email address to invite people to g1t.</p> | |
| 66 | 65 | </section> | |
| 67 | 66 | ); | |
| 68 | 67 | } | |
| 70 | 69 | const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0); | |
| 71 | 70 | return ( | |
| 72 | 71 | <section id="invites" className="scroll-mt-20"> | |
| 73 | − | <h2 className="font-medium">Invites</h2> | |
| 74 | − | <p className="mt-1 text-sm text-muted"> | |
| 72 | + | <p className="text-sm text-muted"> | |
| 75 | 73 | {overview.mode === "invite" | |
| 76 | 74 | ? "g1t is invite-only for now. Each invite lets one person make an account. " | |
| 77 | 75 | : "Anyone can make an account, but an invite still says who sent it. "} |
| 64 | 64 | </span> | |
| 65 | 65 | ); | |
| 66 | 66 | } | |
| 67 | + | ||
| 68 | + | const FOUR = ["...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."]; | |
| 69 | + | const ZERO = [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."]; | |
| 70 | + | /** 4 at 0, the 0 at 6, 4 at 12: the logo's cells and spacing. */ | |
| 71 | + | const FOUR_O_FOUR = [...pixels(FOUR, 0, false), ...pixels(ZERO, 6, true), ...pixels(FOUR, 12, false)]; | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * 404 in the logo's pixels, the 0 in lavender where the logo has its 1. | |
| 75 | + | * Sized by its font size, like the wordmark. | |
| 76 | + | */ | |
| 77 | + | export function Pixel404({ className = "text-[4rem]" }: { className?: string }) { | |
| 78 | + | return ( | |
| 79 | + | <span className={`inline-flex leading-none text-fg ${className}`}> | |
| 80 | + | <svg viewBox="1 1 168 68" role="img" aria-label="404" className="h-[0.85em] w-auto shrink-0" shapeRendering="crispEdges"> | |
| 81 | + | <Pixels of={FOUR_O_FOUR} /> | |
| 82 | + | </svg> | |
| 83 | + | </span> | |
| 84 | + | ); | |
| 85 | + | } |
| 18 | 18 | import { type ReactNode, useEffect, useState } from "react"; | |
| 19 | 19 | import { Link, useRouteLoaderData } from "react-router"; | |
| 20 | 20 | ||
| 21 | − | import { CHANGELOG, changelogHref } from "../lib/changelog"; | |
| 22 | 21 | import { trialClosed } from "../lib/trial"; | |
| 23 | 22 | import { type Need, formatSpan, greetingFor, rankNeeds } from "../lib/mission"; | |
| 24 | 23 | import { AgentSetup } from "./agent-setup"; | |
| 377 | 376 | ); | |
| 378 | 377 | } | |
| 379 | 378 | ||
| 380 | − | function WhatsNew({ workspace }: { workspace: string | null }) { | |
| 381 | − | return ( | |
| 382 | − | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 383 | − | <h2 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 384 | − | <Sparkles size={14} className="text-accent" /> | |
| 385 | − | What's new on g1t | |
| 386 | − | </h2> | |
| 387 | − | <ul className="mt-3 space-y-3"> | |
| 388 | − | {CHANGELOG.slice(0, 5).map((entry) => { | |
| 389 | − | const href = changelogHref(entry, workspace); | |
| 390 | − | const title = <span className="text-sm font-medium">{entry.title}</span>; | |
| 391 | − | return ( | |
| 392 | − | <li key={entry.title}> | |
| 393 | − | {href ? ( | |
| 394 | − | <Link to={href} className="hover:text-accent"> | |
| 395 | − | {title} | |
| 396 | − | </Link> | |
| 397 | − | ) : ( | |
| 398 | − | title | |
| 399 | − | )} | |
| 400 | − | <p className="mt-0.5 text-xs leading-5 text-muted">{entry.about}</p> | |
| 401 | − | </li> | |
| 402 | − | ); | |
| 403 | − | })} | |
| 404 | − | </ul> | |
| 405 | − | <a href="https://docs.g1t.sh/" className="mt-4 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 406 | − | Read the docs <ArrowRight size={12} /> | |
| 407 | − | </a> | |
| 408 | − | </div> | |
| 409 | − | ); | |
| 410 | − | } | |
| 411 | 379 | ||
| 412 | 380 | // --- The page ------------------------------------------------------------------- | |
| 413 | 381 | ||
| 695 | 663 | /> | |
| 696 | 664 | )} | |
| 697 | 665 | {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />} | |
| 698 | − | <WhatsNew workspace={workspace} /> | |
| 699 | 666 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 700 | 667 | <h2 className="text-sm font-semibold">Connect your own agent</h2> | |
| 701 | 668 | <p className="mt-1.5 text-xs leading-5 text-muted"> |
| 1 | − | import { EyeOff, KeyRound, UserX } from "lucide-react"; | |
| 2 | 1 | import { Form, Link, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 2 | ||
| 4 | − | import type { User } from "@g1t/contracts"; | |
| 3 | + | import type { Membership, User } from "@g1t/contracts"; | |
| 5 | 4 | ||
| 6 | 5 | import { withNext } from "../lib/next"; | |
| 7 | 6 | import { useSignUpCopy } from "../lib/registration"; | |
| 8 | 7 | import { missingKind, notFoundCopy } from "../lib/not-found"; | |
| 9 | − | import { Avatar, ButtonLink } from "./ui"; | |
| 8 | + | import { Pixel404 } from "./logo"; | |
| 9 | + | import { ButtonLink } from "./ui"; | |
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | 12 | * The page for anything someone cannot see. It reads only the kind of thing | |
| 13 | 13 | * the address names and who is signed in, so a private project and a | |
| 14 | − | * missing one give the same page, word for word. | |
| 14 | + | * missing one give the same page, word for word, and the same 404. | |
| 15 | 15 | */ | |
| 16 | 16 | export function NotFound({ data }: { data?: unknown }) { | |
| 17 | 17 | const { pathname, search } = useLocation(); | |
| 18 | − | const user = (useRouteLoaderData("root") as { user?: User | null } | undefined)?.user ?? null; | |
| 18 | + | const root = useRouteLoaderData("root") as | |
| 19 | + | | { user?: User | null; shell?: { workspace?: Membership | null; repo?: { namespace: string; name: string } | null } } | |
| 20 | + | | undefined; | |
| 21 | + | const user = root?.user ?? null; | |
| 22 | + | const workspace = user ? (root?.shell?.workspace ?? null) : null; | |
| 23 | + | // Something missing inside a project the viewer can see: back to it. | |
| 24 | + | const project = root?.shell?.repo ?? null; | |
| 25 | + | const inProject = | |
| 26 | + | project != null && pathname.toLowerCase().startsWith(`/${project.namespace}/${project.name}/`.toLowerCase()); | |
| 19 | 27 | const kind = missingKind(data, pathname); | |
| 20 | 28 | const copy = notFoundCopy(kind, user?.username); | |
| 21 | 29 | const here = pathname + search; | |
| 22 | 30 | const signUp = useSignUpCopy(); | |
| 23 | − | const Icon = kind === "person" ? UserX : copy.signIn ? KeyRound : EyeOff; | |
| 24 | 31 | return ( | |
| 25 | − | <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center sm:py-32"> | |
| 26 | − | <span className="flex size-11 items-center justify-center rounded-xl bg-surface text-muted ring-1 ring-line"> | |
| 27 | − | <Icon size={20} /> | |
| 28 | − | </span> | |
| 29 | − | <h1 className="mt-6 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1> | |
| 30 | − | {copy.body && <p className="mt-3 text-muted">{copy.body}</p>} | |
| 32 | + | <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-20 text-center sm:py-28"> | |
| 33 | + | <Pixel404 className="text-[3.5rem] sm:text-[4.5rem]" /> | |
| 34 | + | <h1 className="mt-10 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1> | |
| 35 | + | <p className="mt-2 text-balance text-muted">{copy.body}</p> | |
| 31 | 36 | ||
| 37 | + | <div className="mt-8 flex flex-wrap justify-center gap-2"> | |
| 38 | + | {copy.signIn ? ( | |
| 39 | + | <ButtonLink to={withNext("/login", here)}>Sign in</ButtonLink> | |
| 40 | + | ) : kind === "person" ? ( | |
| 41 | + | <ButtonLink to="/search?type=people">Search people</ButtonLink> | |
| 42 | + | ) : inProject ? ( | |
| 43 | + | <ButtonLink to={`/${project.namespace}/${project.name}`}>Go to {project.name}</ButtonLink> | |
| 44 | + | ) : workspace ? ( | |
| 45 | + | <ButtonLink to={`/${workspace.slug}`}>Go to {workspace.name?.trim() || workspace.slug}</ButtonLink> | |
| 46 | + | ) : user ? ( | |
| 47 | + | <ButtonLink to="/">Go to Mission control</ButtonLink> | |
| 48 | + | ) : null} | |
| 49 | + | <ButtonLink to="/explore" variant="quiet"> | |
| 50 | + | {user ? "Explore" : "Explore public projects"} | |
| 51 | + | </ButtonLink> | |
| 52 | + | </div> | |
| 53 | + | ||
| 54 | + | <p className="mt-6 hidden text-sm text-faint sm:block"> | |
| 55 | + | Looking for something? Press{" "} | |
| 56 | + | <kbd className="rounded bg-raised px-1.5 py-0.5 font-mono text-[0.6875rem] text-muted ring-1 ring-line">⌘K</kbd>{" "} | |
| 57 | + | to search. | |
| 58 | + | </p> | |
| 59 | + | ||
| 32 | 60 | {copy.signIn && ( | |
| 33 | − | <> | |
| 34 | − | <div className="mt-8 flex flex-wrap justify-center gap-2"> | |
| 35 | − | <ButtonLink to={withNext("/login", here)}>Sign in</ButtonLink> | |
| 36 | − | <ButtonLink to="/explore" variant="quiet"> | |
| 37 | − | Explore public projects | |
| 38 | − | </ButtonLink> | |
| 39 | − | </div> | |
| 40 | − | <p className="mt-5 text-sm text-muted"> | |
| 41 | − | New to g1t?{" "} | |
| 42 | − | <Link to={withNext("/register", here)} className="font-medium text-fg underline-offset-4 hover:underline"> | |
| 43 | − | {signUp.primary} | |
| 44 | − | </Link> | |
| 45 | − | </p> | |
| 46 | − | </> | |
| 61 | + | <p className="mt-3 text-xs text-faint"> | |
| 62 | + | New to g1t?{" "} | |
| 63 | + | <Link to={withNext("/register", here)} className="text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 64 | + | {signUp.primary} | |
| 65 | + | </Link> | |
| 66 | + | </p> | |
| 47 | 67 | )} | |
| 48 | 68 | ||
| 49 | 69 | {copy.signedInAs && ( | |
| 50 | − | <> | |
| 51 | − | <div className="mt-6 flex w-full flex-col items-center gap-3 rounded-lg bg-surface px-4 py-4 text-sm ring-1 ring-line"> | |
| 52 | − | <p className="flex items-center gap-2 text-muted"> | |
| 53 | − | <Avatar name={copy.signedInAs} image={user?.avatar} size={20} /> | |
| 54 | − | Signed in as <span className="font-mono font-medium text-fg">@{copy.signedInAs}</span> | |
| 55 | − | </p> | |
| 56 | − | {/* Signing out, then in again, comes back here. */} | |
| 57 | − | <Form method="post" action={`/logout?next=${encodeURIComponent(withNext("/login", here))}`}> | |
| 58 | − | <span className="text-faint">Not you? </span> | |
| 59 | − | <button type="submit" className="font-medium text-fg underline-offset-4 hover:underline"> | |
| 60 | − | Switch account | |
| 61 | − | </button> | |
| 62 | − | </Form> | |
| 63 | − | </div> | |
| 64 | − | {copy.askOwner && ( | |
| 65 | − | <p className="mt-4 text-sm text-muted"> | |
| 66 | − | If you should have access, ask an owner of the workspace to add you. | |
| 67 | − | </p> | |
| 68 | − | )} | |
| 69 | − | <div className="mt-8 flex flex-wrap justify-center gap-2"> | |
| 70 | − | <ButtonLink to="/">Back to Mission control</ButtonLink> | |
| 71 | − | <ButtonLink to="/explore" variant="quiet"> | |
| 72 | − | Explore | |
| 73 | − | </ButtonLink> | |
| 74 | − | </div> | |
| 75 | − | </> | |
| 76 | − | )} | |
| 77 | − | ||
| 78 | − | {kind === "person" && ( | |
| 79 | − | <div className="mt-8 flex flex-wrap justify-center gap-2"> | |
| 80 | − | <ButtonLink to="/search?type=people">Search people</ButtonLink> | |
| 81 | − | <ButtonLink to="/explore" variant="quiet"> | |
| 82 | − | Explore | |
| 83 | − | </ButtonLink> | |
| 84 | − | </div> | |
| 70 | + | // Signing out, then in again, comes back here. | |
| 71 | + | <Form | |
| 72 | + | method="post" | |
| 73 | + | action={`/logout?next=${encodeURIComponent(withNext("/login", here))}`} | |
| 74 | + | className="mt-3 text-xs text-faint" | |
| 75 | + | > | |
| 76 | + | Signed in as <span className="font-mono text-muted">@{copy.signedInAs}</span> ·{" "} | |
| 77 | + | <button type="submit" className="text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 78 | + | Switch account | |
| 79 | + | </button> | |
| 80 | + | </Form> | |
| 85 | 81 | )} | |
| 86 | 82 | </main> | |
| 87 | 83 | ); |
| 30 | 30 | return ( | |
| 31 | 31 | <section id="profile" className="scroll-mt-20"> | |
| 32 | 32 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 33 | − | <h2 className="font-medium">Profile</h2> | |
| 33 | + | <h2 className="font-medium">Public profile</h2> | |
| 34 | 34 | <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 35 | 35 | View your profile | |
| 36 | 36 | </Link> |
| 11 | 11 | ChevronLeft, | |
| 12 | 12 | ChevronRight, | |
| 13 | 13 | ChevronsUpDown, | |
| 14 | − | Ellipsis, | |
| 14 | + | CircleUserRound, | |
| 15 | + | Mail, | |
| 16 | + | Ticket, | |
| 15 | 17 | CircleDot, | |
| 16 | 18 | Code2, | |
| 17 | 19 | Compass, | |
| 47 | 49 | X, | |
| 48 | 50 | } from "lucide-react"; | |
| 49 | 51 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 50 | − | import { Form, Link, NavLink, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 52 | + | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 51 | 53 | ||
| 52 | 54 | import type { Abilities, Membership, Spike, User } from "@g1t/contracts"; | |
| 53 | 55 | ||
| 54 | 56 | import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette"; | |
| 55 | − | import { LegalRow } from "./footer"; | |
| 56 | − | import { Logo, Mark } from "./logo"; | |
| 57 | + | import { StatusDot, useSiteStatus } from "./footer"; | |
| 58 | + | import { Logo } from "./logo"; | |
| 57 | 59 | import { Avatar, notACredential } from "./ui"; | |
| 58 | 60 | import { | |
| 59 | 61 | DropdownMenu, | |
| 60 | 62 | DropdownMenuContent, | |
| 63 | + | DropdownMenuGroup, | |
| 61 | 64 | DropdownMenuItem, | |
| 62 | 65 | DropdownMenuLabel, | |
| 63 | 66 | DropdownMenuSeparator, | |
| 66 | 69 | import { type RoadmapItem, roadmapIn } from "../lib/roadmap"; | |
| 67 | 70 | import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access"; | |
| 68 | 71 | import { VISITOR_LINKS, projectPages } from "../lib/chrome"; | |
| 72 | + | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; | |
| 73 | + | import { GithubMark } from "./github"; | |
| 69 | 74 | import { withNext } from "../lib/next"; | |
| 70 | 75 | import { useSignUpCopy } from "../lib/registration"; | |
| 76 | + | import { STATUS_URL, statusTitle } from "../lib/status"; | |
| 77 | + | import type { AccountMenuData } from "../routes/settings-menu-json"; | |
| 71 | 78 | ||
| 72 | 79 | /** | |
| 73 | 80 | * What the sidebar needs, worked out by the root loader. For a visitor who | |
| 316 | 323 | ); | |
| 317 | 324 | } | |
| 318 | 325 | ||
| 326 | + | /** | |
| 327 | + | * The fine print, as one quiet row at the foot of the account menu and the | |
| 328 | + | * visitor's panel: each link a full-height target, left-aligned. In the | |
| 329 | + | * menu each is a menu item, so the arrow keys reach it. | |
| 330 | + | */ | |
| 331 | + | const LEGAL_LINKS: [string, string][] = [ | |
| 332 | + | ["Policies", "/policies"], | |
| 333 | + | ["Privacy", "/policies/privacy"], | |
| 334 | + | ["Security", "/security"], | |
| 335 | + | ]; | |
| 336 | + | const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors"; | |
| 337 | + | ||
| 338 | + | function MenuLegalRow() { | |
| 339 | + | return ( | |
| 340 | + | <div role="group" aria-label="Legal" className="flex items-center gap-0.5 px-0.5 pt-0.5"> | |
| 341 | + | {LEGAL_LINKS.map(([label, to], index) => ( | |
| 342 | + | <span key={to} className="flex items-center gap-0.5"> | |
| 343 | + | {index > 0 && ( | |
| 344 | + | <span aria-hidden="true" className="text-[0.625rem] text-line-strong"> | |
| 345 | + | · | |
| 346 | + | </span> | |
| 347 | + | )} | |
| 348 | + | <DropdownMenuItem asChild className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}> | |
| 349 | + | <Link to={to}>{label}</Link> | |
| 350 | + | </DropdownMenuItem> | |
| 351 | + | </span> | |
| 352 | + | ))} | |
| 353 | + | </div> | |
| 354 | + | ); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /** Status's row: the live dot and two or three words, fetched when the menu opens. */ | |
| 358 | + | function StatusSummary({ open }: { open: boolean }) { | |
| 359 | + | const status = useSiteStatus(open); | |
| 360 | + | const words = statusTitle(status); | |
| 361 | + | return ( | |
| 362 | + | <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint"> | |
| 363 | + | {/* The words say it; the dot's own words for screen readers would repeat them. */} | |
| 364 | + | <span aria-hidden="true" className="flex"> | |
| 365 | + | <StatusDot state={status?.overall.state ?? null} /> | |
| 366 | + | </span> | |
| 367 | + | <span className="truncate">{words ?? "Checking…"}</span> | |
| 368 | + | </span> | |
| 369 | + | ); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /** A menu row: an icon, words, and whatever sits at its end. */ | |
| 373 | + | const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]"; | |
| 374 | + | ||
| 319 | 375 | function AccountMenu({ user }: { user: User }) { | |
| 320 | 376 | const submit = useSubmit(); | |
| 377 | + | const [open, setOpen] = useState(false); | |
| 378 | + | // Name, primary address and invites left: asked for once, when first opened. | |
| 379 | + | const details = useFetcher<AccountMenuData | null>({ key: "account-menu" }); | |
| 380 | + | useEffect(() => { | |
| 381 | + | if (open && details.state === "idle" && details.data === undefined) details.load("/settings/menu.json"); | |
| 382 | + | }, [open, details]); | |
| 383 | + | const me = details.data ?? null; | |
| 384 | + | const profile = `/u/${user.username}`; | |
| 321 | 385 | return ( | |
| 322 | − | <DropdownMenu> | |
| 323 | − | <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"> | |
| 386 | + | <DropdownMenu open={open} onOpenChange={setOpen}> | |
| 387 | + | <DropdownMenuTrigger | |
| 388 | + | aria-label={`Account menu for ${user.username}`} | |
| 389 | + | className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-merged/60 data-[state=open]:bg-raised" | |
| 390 | + | > | |
| 324 | 391 | <Avatar name={user.username} image={user.avatar} size={22} /> | |
| 325 | 392 | <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span> | |
| 326 | − | <Ellipsis size={15} className="shrink-0 text-faint" /> | |
| 393 | + | <ChevronsUpDown size={14} className="shrink-0 text-faint" /> | |
| 327 | 394 | </DropdownMenuTrigger> | |
| 328 | − | <DropdownMenuContent align="start" side="top" className="w-64"> | |
| 329 | − | <DropdownMenuLabel> | |
| 330 | − | Signed in as <span className="font-mono font-medium text-fg">{user.username}</span> | |
| 331 | − | </DropdownMenuLabel> | |
| 332 | − | <DropdownMenuSeparator /> | |
| 333 | − | <DropdownMenuItem asChild> | |
| 334 | − | <Link to="/settings"> | |
| 335 | − | <Settings /> | |
| 336 | − | Your settings | |
| 395 | + | <DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-[17.5rem] p-1.5"> | |
| 396 | + | {/* Who is signed in, and a way to their profile. */} | |
| 397 | + | <DropdownMenuItem asChild className="gap-3 px-2 py-2"> | |
| 398 | + | <Link to={profile} aria-label={`${me?.name ?? user.username} (@${user.username}), your profile`}> | |
| 399 | + | <Avatar name={user.username} image={user.avatar} size={36} /> | |
| 400 | + | <span className="flex min-w-0 flex-col leading-tight"> | |
| 401 | + | <span className="truncate text-sm font-medium text-fg">{me?.name ?? user.username}</span> | |
| 402 | + | <span className="truncate font-mono text-xs text-muted">@{user.username}</span> | |
| 403 | + | {me?.email && <span className="mt-0.5 truncate text-xs text-faint">{me.email}</span>} | |
| 404 | + | </span> | |
| 337 | 405 | </Link> | |
| 338 | 406 | </DropdownMenuItem> | |
| 339 | − | <DropdownMenuItem asChild> | |
| 340 | − | <a href="https://docs.g1t.sh/"> | |
| 341 | − | <BookOpen /> | |
| 342 | − | Documentation | |
| 343 | − | </a> | |
| 344 | − | </DropdownMenuItem> | |
| 345 | − | <DropdownMenuSeparator /> | |
| 407 | + | <DropdownMenuSeparator className="my-1.5" /> | |
| 408 | + | <DropdownMenuGroup> | |
| 409 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 410 | + | <Link to={profile}> | |
| 411 | + | <CircleUserRound /> | |
| 412 | + | Your profile | |
| 413 | + | </Link> | |
| 414 | + | </DropdownMenuItem> | |
| 415 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 416 | + | <Link to={FIRST_SETTINGS_PAGE}> | |
| 417 | + | <Settings /> | |
| 418 | + | Your settings | |
| 419 | + | </Link> | |
| 420 | + | </DropdownMenuItem> | |
| 421 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 422 | + | <Link to="/settings/invites"> | |
| 423 | + | <Ticket /> | |
| 424 | + | Invites | |
| 425 | + | {me?.invites_left != null && ( | |
| 426 | + | <span className="ml-auto rounded-full bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted"> | |
| 427 | + | {me.invites_left} left | |
| 428 | + | </span> | |
| 429 | + | )} | |
| 430 | + | </Link> | |
| 431 | + | </DropdownMenuItem> | |
| 432 | + | </DropdownMenuGroup> | |
| 433 | + | <DropdownMenuSeparator className="my-1.5" /> | |
| 434 | + | <DropdownMenuGroup> | |
| 435 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 436 | + | <a href="https://docs.g1t.sh/"> | |
| 437 | + | <BookOpen /> | |
| 438 | + | Documentation | |
| 439 | + | </a> | |
| 440 | + | </DropdownMenuItem> | |
| 441 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 442 | + | <Link to="/support"> | |
| 443 | + | <LifeBuoy /> | |
| 444 | + | Support | |
| 445 | + | </Link> | |
| 446 | + | </DropdownMenuItem> | |
| 447 | + | <DropdownMenuItem asChild className={MENU_ROW}> | |
| 448 | + | <a href={STATUS_URL}> | |
| 449 | + | <Activity /> | |
| 450 | + | Status | |
| 451 | + | <StatusSummary open={open} /> | |
| 452 | + | </a> | |
| 453 | + | </DropdownMenuItem> | |
| 454 | + | </DropdownMenuGroup> | |
| 455 | + | <DropdownMenuSeparator className="my-1.5" /> | |
| 346 | 456 | {/* Submitted from here: the menu closes on select, and a button | |
| 347 | 457 | that has left the page cannot submit a form. */} | |
| 348 | − | <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}> | |
| 458 | + | <DropdownMenuItem className={MENU_ROW} onSelect={() => submit(null, { method: "post", action: "/logout" })}> | |
| 349 | 459 | <LogOut /> | |
| 350 | 460 | Sign out | |
| 351 | 461 | </DropdownMenuItem> | |
| 352 | − | {/* The site footer's row, slim: the app has no footer of its own. | |
| 353 | − | Rendered only while the menu is open, so status is fetched then. */} | |
| 354 | − | <DropdownMenuSeparator /> | |
| 355 | − | <div className="px-1.5 pt-1 pb-1.5"> | |
| 356 | − | <LegalRow user={user} compact /> | |
| 357 | − | </div> | |
| 462 | + | <DropdownMenuSeparator className="my-1.5" /> | |
| 463 | + | <MenuLegalRow /> | |
| 358 | 464 | </DropdownMenuContent> | |
| 359 | 465 | </DropdownMenu> | |
| 360 | 466 | ); | |
| 849 | 955 | ); | |
| 850 | 956 | } | |
| 851 | 957 | ||
| 852 | − | /** Your own settings, as the sidebar shows them on the settings page. */ | |
| 853 | − | function AccountSettingsMenu() { | |
| 854 | − | const { hash } = useLocation(); | |
| 855 | − | const item = (id: string, icon: ReactNode, label: string) => ( | |
| 856 | − | <Link | |
| 857 | − | to={`/settings#${id}`} | |
| 858 | − | className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 859 | − | hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 860 | − | }`} | |
| 861 | − | > | |
| 862 | − | <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span> | |
| 863 | − | {label} | |
| 864 | − | </Link> | |
| 958 | + | /** Your own settings, drilled into from Your settings: one page each. */ | |
| 959 | + | function AccountSettingsMenu({ username }: { username: string }) { | |
| 960 | + | const link = (page: AccountSettingsPage, icon: ReactNode) => ( | |
| 961 | + | <SidebarLink to={`/settings/${page}`} icon={icon}> | |
| 962 | + | {ACCOUNT_SETTINGS[page].title} | |
| 963 | + | </SidebarLink> | |
| 865 | 964 | ); | |
| 866 | 965 | return ( | |
| 867 | 966 | <nav aria-label="Your settings" className={PANEL}> | |
| 868 | − | <BackRow to="/" label="Your settings" /> | |
| 967 | + | <BackRow to="/" label="Your settings" context={username} /> | |
| 869 | 968 | <div className="mt-2 space-y-px"> | |
| 870 | − | {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")} | |
| 871 | − | {item("tokens", <KeyRound size={15} />, "Access tokens")} | |
| 872 | − | {item("applications", <Plug size={15} />, "Connected applications")} | |
| 969 | + | {link("profile", <CircleUserRound size={15} />)} | |
| 970 | + | {link("emails", <Mail size={15} />)} | |
| 971 | + | {link("invites", <Ticket size={15} />)} | |
| 972 | + | </div> | |
| 973 | + | <Rule /> | |
| 974 | + | <div className="space-y-px"> | |
| 975 | + | {link("keys", <Fingerprint size={15} />)} | |
| 976 | + | {link("tokens", <KeyRound size={15} />)} | |
| 873 | 977 | </div> | |
| 978 | + | <Rule /> | |
| 979 | + | <div className="space-y-px"> | |
| 980 | + | {link("github", <GithubMark className="size-[15px]" />)} | |
| 981 | + | {link("applications", <Plug size={15} />)} | |
| 982 | + | </div> | |
| 983 | + | <Rule /> | |
| 984 | + | <div className="space-y-px"> | |
| 985 | + | {link("security-log", <History size={15} />)} | |
| 986 | + | </div> | |
| 874 | 987 | </nav> | |
| 875 | 988 | ); | |
| 876 | 989 | } | |
| 879 | 992 | * Signing in and signing up, in place of the account for a visitor. Signing | |
| 880 | 993 | * in brings them back to the page they are on. | |
| 881 | 994 | */ | |
| 995 | + | /** Help, status and the fine print under a visitor's sign-in buttons. */ | |
| 996 | + | function VisitorLinks() { | |
| 997 | + | const status = useSiteStatus(); | |
| 998 | + | const quiet = `${LEGAL_LINK} hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-merged/60`; | |
| 999 | + | const dot = ( | |
| 1000 | + | <span aria-hidden="true" className="text-[0.625rem] text-line-strong"> | |
| 1001 | + | · | |
| 1002 | + | </span> | |
| 1003 | + | ); | |
| 1004 | + | return ( | |
| 1005 | + | // Two short rows, so a narrow sidebar never starts a line with a dot. | |
| 1006 | + | <nav aria-label="About g1t" className="pt-0.5"> | |
| 1007 | + | <div className="flex items-center gap-0.5"> | |
| 1008 | + | <a href={STATUS_URL} className={`${quiet} gap-1.5`}> | |
| 1009 | + | <StatusDot state={status?.overall.state ?? null} /> | |
| 1010 | + | Status | |
| 1011 | + | </a> | |
| 1012 | + | {dot} | |
| 1013 | + | <Link to="/support" className={quiet}> | |
| 1014 | + | Support | |
| 1015 | + | </Link> | |
| 1016 | + | </div> | |
| 1017 | + | <div className="flex items-center gap-0.5"> | |
| 1018 | + | {LEGAL_LINKS.map(([label, to], index) => ( | |
| 1019 | + | <span key={to} className="flex items-center gap-0.5"> | |
| 1020 | + | {index > 0 && dot} | |
| 1021 | + | <Link to={to} className={quiet}> | |
| 1022 | + | {label} | |
| 1023 | + | </Link> | |
| 1024 | + | </span> | |
| 1025 | + | ))} | |
| 1026 | + | </div> | |
| 1027 | + | </nav> | |
| 1028 | + | ); | |
| 1029 | + | } | |
| 1030 | + | ||
| 882 | 1031 | function VisitorPanel() { | |
| 883 | 1032 | const { pathname, search } = useLocation(); | |
| 884 | 1033 | const signUp = useSignUpCopy(); | |
| 900 | 1049 | {signUp.primary} | |
| 901 | 1050 | </Link> | |
| 902 | 1051 | </div> | |
| 903 | − | <nav aria-label="About g1t" className="flex flex-wrap justify-center gap-x-2 gap-y-0.5 px-1 pt-1 text-[0.6875rem] text-faint"> | |
| 904 | − | <Link to="/status" className="hover:text-fg">Status</Link> | |
| 905 | − | <Link to="/support" className="hover:text-fg">Support</Link> | |
| 906 | − | <Link to="/policies" className="hover:text-fg">Policies</Link> | |
| 907 | − | <Link to="/policies/privacy" className="hover:text-fg">Privacy</Link> | |
| 908 | − | <Link to="/security" className="hover:text-fg">Security</Link> | |
| 909 | − | </nav> | |
| 1052 | + | <VisitorLinks /> | |
| 910 | 1053 | </div> | |
| 911 | 1054 | ); | |
| 912 | 1055 | } | |
| 914 | 1057 | function Sidebar({ | |
| 915 | 1058 | user, | |
| 916 | 1059 | shell, | |
| 1060 | + | missing = false, | |
| 917 | 1061 | onFind, | |
| 918 | 1062 | onClose, | |
| 919 | 1063 | }: { | |
| 920 | 1064 | user: User | null; | |
| 921 | 1065 | shell: ShellData; | |
| 1066 | + | /** The page is a 404: the address names nothing the viewer can see. */ | |
| 1067 | + | missing?: boolean; | |
| 922 | 1068 | onFind: () => void; | |
| 923 | 1069 | /** In the sheet on a small screen: closing it, at the end of the top row. */ | |
| 924 | 1070 | onClose?: () => void; | |
| 929 | 1075 | // Where the sidebar is drilled to follows the page being gone to, so it | |
| 930 | 1076 | // moves as the link is followed, not once the page arrives. | |
| 931 | 1077 | const target = going ?? pathname; | |
| 932 | − | const inSettings = ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug; | |
| 933 | − | const inAccount = target === "/settings"; | |
| 1078 | + | // On a 404, the address is not a place: no list is drilled into from it, | |
| 1079 | + | // except the project's own when the project is real and visible and only | |
| 1080 | + | // something inside it is missing (a file, a commit, an issue). | |
| 1081 | + | const lost = missing && going == null; | |
| 1082 | + | const inSettings = !lost && ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug; | |
| 1083 | + | const inAccount = !lost && target === "/settings" || target.startsWith("/settings/"); | |
| 934 | 1084 | const active = shell.repo; | |
| 935 | 1085 | // In a repository, or on the way into one, its own list. | |
| 936 | 1086 | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); | |
| 937 | 1087 | const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", ".well-known"]); | |
| 938 | 1088 | // An invitation to a repository is answered before its menu means anything. | |
| 939 | 1089 | const inRepo = | |
| 940 | − | repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-" && !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target); | |
| 1090 | + | repoPath != null && | |
| 1091 | + | !reserved.has(repoPath[1]) && | |
| 1092 | + | repoPath[2] !== "-" && | |
| 1093 | + | !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) && | |
| 1094 | + | (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! })); | |
| 941 | 1095 | // A project's settings, one level further in. | |
| 942 | 1096 | const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target); | |
| 943 | 1097 | // The repository the menu is for: the one loaded if it is the one being | |
| 963 | 1117 | // The way from the main list to the one shown. | |
| 964 | 1118 | const trail: Level[] = [{ key: "main", node: <MainMenu user={user} shell={shell} /> }]; | |
| 965 | 1119 | if (user && inAccount) { | |
| 966 | − | trail.push({ key: "account", node: <AccountSettingsMenu /> }); | |
| 1120 | + | trail.push({ key: "account", node: <AccountSettingsMenu username={user.username} /> }); | |
| 967 | 1121 | } else if (ws && inSettings) { | |
| 968 | 1122 | trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> }); | |
| 969 | 1123 | } else if (menuRepo) { | |
| 994 | 1148 | <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5"> | |
| 995 | 1149 | {user ? ( | |
| 996 | 1150 | <> | |
| 997 | − | <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised"> | |
| 998 | − | <Mark className="size-6" /> | |
| 1151 | + | <Link to="/" aria-label="g1t home" className="flex h-9 shrink-0 items-center rounded-md px-2 transition-colors hover:bg-raised"> | |
| 1152 | + | <Logo className="text-[1.25rem]" /> | |
| 999 | 1153 | </Link> | |
| 1000 | 1154 | <span className="shrink-0 text-line-strong" aria-hidden="true"> | |
| 1001 | 1155 | / | |
| 1003 | 1157 | <WorkspaceSwitcher user={user} shell={shell} /> | |
| 1004 | 1158 | </> | |
| 1005 | 1159 | ) : ( | |
| 1006 | − | <Link to="/" aria-label="g1t home" className="mr-auto flex rounded-md px-1.5 py-1.5 hover:bg-raised"> | |
| 1007 | − | <Logo /> | |
| 1160 | + | <Link to="/" aria-label="g1t home" className="mr-auto flex h-9 items-center rounded-md px-2 transition-colors hover:bg-raised"> | |
| 1161 | + | <Logo className="text-[1.25rem]" /> | |
| 1008 | 1162 | </Link> | |
| 1009 | 1163 | )} | |
| 1010 | 1164 | {onClose && ( | |
| 1071 | 1225 | }; | |
| 1072 | 1226 | ||
| 1073 | 1227 | /** Where the page is, as a trail of links: workspace / repository / section. */ | |
| 1074 | − | function Breadcrumbs({ pathname }: { pathname: string }) { | |
| 1228 | + | function Breadcrumbs({ pathname, missing, repo }: { pathname: string; missing?: boolean; repo?: ShellData["repo"] }) { | |
| 1229 | + | // A 404 names nothing from the address, unless the project is real and | |
| 1230 | + | // visible and only something inside it is missing. | |
| 1231 | + | const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname); | |
| 1232 | + | if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) { | |
| 1233 | + | return <span className="text-sm font-medium">Not found</span>; | |
| 1234 | + | } | |
| 1075 | 1235 | const parts = pathname.split("/").filter(Boolean); | |
| 1076 | 1236 | const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite"]; | |
| 1077 | 1237 | if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>; | |
| 1238 | + | // Your settings: Settings / Emails. | |
| 1239 | + | if (parts[0] === "settings") { | |
| 1240 | + | const page = accountSettingsPage(pathname); | |
| 1241 | + | const trail = [{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])]; | |
| 1242 | + | return ( | |
| 1243 | + | <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm"> | |
| 1244 | + | {trail.map((crumb, index) => ( | |
| 1245 | + | <span key={crumb.to + index} className="flex min-w-0 items-center gap-1.5"> | |
| 1246 | + | {index > 0 && <span className="text-line-strong">/</span>} | |
| 1247 | + | <Link | |
| 1248 | + | to={crumb.to} | |
| 1249 | + | className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${ | |
| 1250 | + | index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg" | |
| 1251 | + | }`} | |
| 1252 | + | > | |
| 1253 | + | {crumb.label} | |
| 1254 | + | </Link> | |
| 1255 | + | </span> | |
| 1256 | + | ))} | |
| 1257 | + | </nav> | |
| 1258 | + | ); | |
| 1259 | + | } | |
| 1078 | 1260 | if (reserved.includes(parts[0]!)) { | |
| 1079 | 1261 | const words: Record<string, string> = { | |
| 1080 | 1262 | settings: "Account", | |
| 1146 | 1328 | { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> }, | |
| 1147 | 1329 | { label: "New project", to: "/new", icon: <Plus size={15} /> }, | |
| 1148 | 1330 | { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> }, | |
| 1149 | − | { label: "Your settings", to: "/settings", icon: <Settings size={15} /> }, | |
| 1331 | + | { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> }, | |
| 1332 | + | ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[]) | |
| 1333 | + | .filter((page) => page !== "profile") | |
| 1334 | + | .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })), | |
| 1150 | 1335 | ]; | |
| 1151 | 1336 | const repo = shell.repo; | |
| 1152 | 1337 | if (repo) { | |
| 1267 | 1452 | export function AppShell({ | |
| 1268 | 1453 | user, | |
| 1269 | 1454 | shell, | |
| 1455 | + | missing = false, | |
| 1270 | 1456 | banner, | |
| 1271 | 1457 | children, | |
| 1272 | 1458 | }: { | |
| 1273 | 1459 | user: User | null; | |
| 1274 | 1460 | shell: ShellData; | |
| 1461 | + | /** The page is a 404 (see Sidebar). */ | |
| 1462 | + | missing?: boolean; | |
| 1275 | 1463 | banner?: ReactNode; | |
| 1276 | 1464 | children: ReactNode; | |
| 1277 | 1465 | }) { | |
| 1290 | 1478 | <div className="min-h-screen"> | |
| 1291 | 1479 | <Progress /> | |
| 1292 | 1480 | <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block"> | |
| 1293 | − | <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} /> | |
| 1481 | + | <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} /> | |
| 1294 | 1482 | </aside> | |
| 1295 | 1483 | {drawer && ( | |
| 1296 | 1484 | <div className="fixed inset-0 z-50 lg:hidden"> | |
| 1301 | 1489 | onClick={() => setDrawer(false)} | |
| 1302 | 1490 | /> | |
| 1303 | 1491 | <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface"> | |
| 1304 | − | <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} /> | |
| 1492 | + | <Sidebar user={user} shell={shell} missing={missing} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} /> | |
| 1305 | 1493 | </aside> | |
| 1306 | 1494 | </div> | |
| 1307 | 1495 | )} | |
| 1316 | 1504 | > | |
| 1317 | 1505 | <Menu size={18} /> | |
| 1318 | 1506 | </button> | |
| 1319 | − | <Breadcrumbs pathname={pathname} /> | |
| 1507 | + | <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} /> | |
| 1320 | 1508 | <Form action="/search" role="search" className="relative ml-auto hidden w-full max-w-64 md:block"> | |
| 1321 | 1509 | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 1322 | 1510 | <input |
| 1 | + | import { PartyPopper } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * The one-time "You're in" at the top of the workspace or repository an | |
| 6 | + | * invite led to, on the first view after joining (lib/invites.ts's | |
| 7 | + | * welcome cookie, cleared as it is shown). | |
| 8 | + | */ | |
| 9 | + | export function WelcomeBanner({ title, children }: { title: string; children: ReactNode }) { | |
| 10 | + | return ( | |
| 11 | + | <div role="status" className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3"> | |
| 12 | + | <PartyPopper size={18} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" /> | |
| 13 | + | <div className="min-w-0 text-sm"> | |
| 14 | + | <p className="font-medium text-fg">{title}</p> | |
| 15 | + | <p className="mt-0.5 text-muted">{children}</p> | |
| 16 | + | </div> | |
| 17 | + | </div> | |
| 18 | + | ); | |
| 19 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { ACCOUNT_SETTINGS, accountSettingsPage, settingsPathForHash } from "./account-settings.ts"; | |
| 5 | + | ||
| 6 | + | test("every anchor of the old settings page lands on its own page", () => { | |
| 7 | + | const cases: [string, string][] = [ | |
| 8 | + | ["#picture", "/settings/profile"], | |
| 9 | + | ["#profile", "/settings/profile"], | |
| 10 | + | ["#emails", "/settings/emails"], | |
| 11 | + | ["#github", "/settings/github"], | |
| 12 | + | ["#invites", "/settings/invites"], | |
| 13 | + | ["#ssh-keys", "/settings/keys"], | |
| 14 | + | ["#tokens", "/settings/tokens"], | |
| 15 | + | ["#applications", "/settings/applications"], | |
| 16 | + | ["#security-log", "/settings/security-log"], | |
| 17 | + | ]; | |
| 18 | + | for (const [hash, path] of cases) assert.equal(settingsPathForHash(hash), path, hash); | |
| 19 | + | assert.equal(settingsPathForHash(""), null); | |
| 20 | + | assert.equal(settingsPathForHash("#nothing"), null); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("a settings path names its page", () => { | |
| 24 | + | for (const page of Object.keys(ACCOUNT_SETTINGS)) { | |
| 25 | + | assert.equal(accountSettingsPage(`/settings/${page}`), page); | |
| 26 | + | } | |
| 27 | + | assert.equal(accountSettingsPage("/settings"), null); | |
| 28 | + | assert.equal(accountSettingsPage("/settings/nope"), null); | |
| 29 | + | assert.equal(accountSettingsPage("/acme/web/settings/domains"), null); | |
| 30 | + | }); |
| 1 | + | /** | |
| 2 | + | * Your own settings: one page for each part, at `/settings/<page>`, so the | |
| 3 | + | * docs, emails and messages can link to exactly the one they mean. | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | export type AccountSettingsPage = | |
| 7 | + | | "profile" | |
| 8 | + | | "emails" | |
| 9 | + | | "invites" | |
| 10 | + | | "keys" | |
| 11 | + | | "tokens" | |
| 12 | + | | "github" | |
| 13 | + | | "applications" | |
| 14 | + | | "security-log"; | |
| 15 | + | ||
| 16 | + | /** Each page's name and what it is for, in the sidebar's order. */ | |
| 17 | + | export const ACCOUNT_SETTINGS: Record<AccountSettingsPage, { title: string; about: string }> = { | |
| 18 | + | profile: { title: "Profile", about: "Your picture, and what everyone sees on your profile." }, | |
| 19 | + | emails: { | |
| 20 | + | title: "Emails", | |
| 21 | + | about: | |
| 22 | + | "Your primary address gets account mail and password resets. Any confirmed address signs you in and can reset your password, and commits that carry it are shown as yours.", | |
| 23 | + | }, | |
| 24 | + | invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." }, | |
| 25 | + | keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." }, | |
| 26 | + | tokens: { | |
| 27 | + | title: "Access tokens", | |
| 28 | + | about: | |
| 29 | + | "Use a token as the password when git asks for one over HTTPS, and to authenticate agents and the API. A token here acts as you.", | |
| 30 | + | }, | |
| 31 | + | github: { | |
| 32 | + | title: "GitHub", | |
| 33 | + | about: "Sign in with your GitHub account, and import or mirror repositories you can reach there.", | |
| 34 | + | }, | |
| 35 | + | applications: { | |
| 36 | + | title: "Connected applications", | |
| 37 | + | about: | |
| 38 | + | "Applications you signed in to through your browser, such as an agent connected to the g1t MCP server. Signing one out ends its access at once.", | |
| 39 | + | }, | |
| 40 | + | "security-log": { | |
| 41 | + | title: "Security log", | |
| 42 | + | about: | |
| 43 | + | "Changes to your addresses and password, by you or by g1t staff. If you do not recognise one, reset your password.", | |
| 44 | + | }, | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | /** Where the first page is: `/settings` goes there. */ | |
| 48 | + | export const FIRST_SETTINGS_PAGE = "/settings/profile"; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * The sections of the old single settings page, by their `#anchor`, and | |
| 52 | + | * the page each now has. Links like `g1t.sh/settings#emails` in old email | |
| 53 | + | * and bookmarks still land in the right place. | |
| 54 | + | */ | |
| 55 | + | const OLD_ANCHORS: Record<string, AccountSettingsPage> = { | |
| 56 | + | picture: "profile", | |
| 57 | + | profile: "profile", | |
| 58 | + | emails: "emails", | |
| 59 | + | github: "github", | |
| 60 | + | invites: "invites", | |
| 61 | + | "ssh-keys": "keys", | |
| 62 | + | tokens: "tokens", | |
| 63 | + | applications: "applications", | |
| 64 | + | "security-log": "security-log", | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | /** The page an old `#anchor` of the settings page now lives at, if it was one. */ | |
| 68 | + | export function settingsPathForHash(hash: string): string | null { | |
| 69 | + | const page = OLD_ANCHORS[hash.replace(/^#/, "")]; | |
| 70 | + | return page ? `/settings/${page}` : null; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** Which settings page a path is, or null when it is none. */ | |
| 74 | + | export function accountSettingsPage(pathname: string): AccountSettingsPage | null { | |
| 75 | + | const match = /^\/settings\/([^/]+)\/?$/.exec(pathname); | |
| 76 | + | const page = match?.[1]; | |
| 77 | + | return page && page in ACCOUNT_SETTINGS ? (page as AccountSettingsPage) : null; | |
| 78 | + | } |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | + | gigabytes, | |
| 6 | + | shownMeters, | |
| 5 | 7 | alertText, | |
| 6 | 8 | alertTone, | |
| 7 | 9 | cardCheckResult, | |
| 201 | 203 | assert.equal(requestStatus({ ...request, status: "approved", decidedMicros: 2_000_000_000 }), "Approved at $2,000"); | |
| 202 | 204 | assert.equal(requestStatus({ ...request, status: "declined" }), "Declined"); | |
| 203 | 205 | }); | |
| 206 | + | ||
| 207 | + | const METERS = [ | |
| 208 | + | { key: "agents", label: "Agents & sandboxes", micros: 1_200_000, quantity: "4 runs" }, | |
| 209 | + | { key: "builds", label: "Builds", micros: 0, quantity: null }, | |
| 210 | + | { key: "requests", label: "Requests & CPU", micros: 0, quantity: null }, | |
| 211 | + | { key: "domains", label: "Custom domains", micros: 120_000, quantity: "1 custom domain" }, | |
| 212 | + | { key: "git_storage", label: "Git operations & storage", micros: 0, quantity: "1,200 git operations" }, | |
| 213 | + | { key: "search_scans", label: "Search & security scans", micros: 3_000, quantity: null }, | |
| 214 | + | ]; | |
| 215 | + | ||
| 216 | + | test("the plan shows every meter, with no quota beside any of them", () => { | |
| 217 | + | const shown = shownMeters(METERS, true); | |
| 218 | + | assert.deepEqual( | |
| 219 | + | shown.map((m) => m.key), | |
| 220 | + | ["agents", "builds", "requests", "domains", "git_storage", "search_scans"], | |
| 221 | + | ); | |
| 222 | + | for (const meter of shown) assert.ok(!/ of |apps up/i.test(`${meter.label} ${meter.quantity ?? ""}`)); | |
| 223 | + | }); | |
| 224 | + | ||
| 225 | + | test("a free workspace sees only what it can use, and anything it used anyway", () => { | |
| 226 | + | assert.deepEqual( | |
| 227 | + | shownMeters(METERS, false).map((m) => m.key), | |
| 228 | + | ["agents", "domains", "git_storage", "search_scans"], | |
| 229 | + | ); | |
| 230 | + | }); | |
| 231 | + | ||
| 232 | + | test("storage reads in powers of ten", () => { | |
| 233 | + | assert.equal(gigabytes(1_000_000_000), "1 GB"); | |
| 234 | + | assert.equal(gigabytes(1_500_000_000), "1.5 GB"); | |
| 235 | + | assert.equal(gigabytes(500_000_000), "500 MB"); | |
| 236 | + | }); |
| 3 | 3 | * workspace's alerts. Pure, so it can be tested. | |
| 4 | 4 | */ | |
| 5 | 5 | ||
| 6 | − | import type { Entitlements, FeatureState, Limit, LimitRequest, Usage, UsageAlert } from "@g1t/contracts"; | |
| 6 | + | import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | /** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */ | |
| 9 | 9 | const MICROS_PER_DOLLAR = 1_000_000; | |
| 184 | 184 | return { kind: "free", label: "Free" }; | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | /** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */ | |
| 188 | + | const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]); | |
| 189 | + | ||
| 190 | + | /** | |
| 191 | + | * The lines of "This month's usage": every meter on the plan, so it reads | |
| 192 | + | * the same each month; without it, only what a free workspace can use, | |
| 193 | + | * plus anything that was used anyway (from before the plan ended). | |
| 194 | + | */ | |
| 195 | + | export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] { | |
| 196 | + | return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */ | |
| 200 | + | export function gigabytes(bytes: number): string { | |
| 201 | + | if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`; | |
| 202 | + | return `${Math.round(bytes / 1e6)} MB`; | |
| 203 | + | } | |
| 204 | + | ||
| 187 | 205 | /** A share from 0 to 1 of `used` against `of`, for a meter. */ | |
| 188 | 206 | export function share(used: number, of: number | null | undefined): number { | |
| 189 | 207 | if (!of || of <= 0) return 0; |
| 1 | − | /** | |
| 2 | − | * What is new on g1t, newest first, for the corner of mission control that | |
| 3 | − | * says so. Short and specific: what changed, and where to see it. Add an | |
| 4 | − | * entry in the same change that ships the feature. | |
| 5 | − | */ | |
| 6 | − | export type ChangelogEntry = { | |
| 7 | − | /** `YYYY-MM-DD`. */ | |
| 8 | − | date: string; | |
| 9 | − | title: string; | |
| 10 | − | about: string; | |
| 11 | − | /** Docs, or a page on g1t; `{workspace}` and `{project}` are filled in where known. */ | |
| 12 | − | href: string; | |
| 13 | − | }; | |
| 14 | − | ||
| 15 | − | export const CHANGELOG: ChangelogEntry[] = [ | |
| 16 | − | { | |
| 17 | − | date: "2026-10-05", | |
| 18 | − | title: "Invites", | |
| 19 | − | about: | |
| 20 | − | "g1t is invite-only while we open it up. You have 5 invites: make one for anyone with the link or for one email address, copy it, and revoke it until it is used; an unused one comes back when it expires. Owners can invite an email address straight into a workspace, and it makes the account and joins in one step.", | |
| 21 | − | href: "/settings#invites", | |
| 22 | − | }, | |
| 23 | − | { | |
| 24 | − | date: "2026-10-05", | |
| 25 | − | title: "Status, security and policies", | |
| 26 | − | about: | |
| 27 | − | "g1t.sh/status checks every public part of g1t each minute and says plainly what it can't check yet; the dot beside Status in the footer and your account menu follows it. Security describes what protects your code and how to report a vulnerability, Support says who to write to, and Policies has the terms, privacy policy, acceptable use, refunds and subprocessors, with every change listed.", | |
| 28 | − | href: "/status", | |
| 29 | − | }, | |
| 30 | − | { | |
| 31 | − | date: "2026-10-05", | |
| 32 | − | title: "One plan, and limits you control", | |
| 33 | − | about: | |
| 34 | − | "The forge stays free. The g1t plan is $20 a month per workspace, with $10 of usage included and deployments part of it. Billing now has the $5 trial after a card check, your spend limit with how far you can raise it yourself, Raise my limit, Prepay, caps per run and per issue, and Keep going or Stop when spending spikes.", | |
| 35 | − | href: "/{workspace}/-/billing", | |
| 36 | − | }, | |
| 37 | − | { | |
| 38 | − | date: "2026-10-05", | |
| 39 | − | title: "A project overview that shows the way to production", | |
| 40 | − | about: | |
| 41 | − | "Production shows a screenshot of the live site, taken on each deploy. A checklist counts the steps to production, from first push to a first issue for g1t-agent, each linking to where it is done. Active branches show how far each has moved from main, with its pull request and preview.", | |
| 42 | − | href: "https://docs.g1t.sh/guides/projects/#the-overview", | |
| 43 | − | }, | |
| 44 | − | { | |
| 45 | − | date: "2026-10-05", | |
| 46 | − | title: "Setup for Claude Code, Codex, OpenCode and Cursor", | |
| 47 | − | about: | |
| 48 | − | "Wherever g1t shows how to connect your coding agent, pick yours: the command or config for it, with a copy button. Every block on the page follows your choice, and it is remembered.", | |
| 49 | − | href: "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 50 | − | }, | |
| 51 | − | { | |
| 52 | − | date: "2026-10-05", | |
| 53 | − | title: "Browse public projects in the sidebar", | |
| 54 | − | about: | |
| 55 | − | "Public projects, Explore, Search and profiles use the sidebar whether you are signed in or not. A page you cannot see says so plainly, the same for private and missing, with a way to sign in or switch account.", | |
| 56 | − | href: "https://docs.g1t.sh/guides/git/#browsing-without-an-account", | |
| 57 | − | }, | |
| 58 | − | { | |
| 59 | − | date: "2026-10-05", | |
| 60 | − | title: "Cost plus 20%, on everything", | |
| 61 | − | about: | |
| 62 | − | "Sandbox time is charged at cost plus 20% from the first second, with no free minutes. Runs on your own model provider no longer carry a $0.10 fee: they pay only their sandbox time.", | |
| 63 | − | href: "/pricing", | |
| 64 | − | }, | |
| 65 | − | { | |
| 66 | − | date: "2026-10-05", | |
| 67 | − | title: "Catch up in seconds", | |
| 68 | − | about: | |
| 69 | − | "Catch up with main merges it in at once when the two changed different files. When they overlap, the merge box shows g1t-agent at work, step by step.", | |
| 70 | − | href: "https://docs.g1t.sh/guides/pull-requests/#catching-up", | |
| 71 | − | }, | |
| 72 | − | { | |
| 73 | − | date: "2026-10-04", | |
| 74 | − | title: "Agents and memory", | |
| 75 | − | about: "Watch every agent run live, stop or message it, and see what agents learned about each project.", | |
| 76 | − | href: "/{workspace}/-/agents", | |
| 77 | − | }, | |
| 78 | − | { | |
| 79 | − | date: "2026-10-04", | |
| 80 | − | title: "Checks and conflicts", | |
| 81 | − | about: "Workflow statuses on every pull request, and conflicts found before you press merge.", | |
| 82 | − | href: "https://docs.g1t.sh/guides/pull-requests/", | |
| 83 | − | }, | |
| 84 | − | { | |
| 85 | − | date: "2026-10-04", | |
| 86 | − | title: "Profiles", | |
| 87 | − | about: "Your name, bio and everything you opened, in one place.", | |
| 88 | − | href: "/settings", | |
| 89 | − | }, | |
| 90 | − | { | |
| 91 | − | date: "2026-10-04", | |
| 92 | − | title: "Custom domains", | |
| 93 | − | about: "Serve production on your own domain, with its certificate handled for you.", | |
| 94 | − | href: "https://docs.g1t.sh/guides/deployments/", | |
| 95 | − | }, | |
| 96 | − | { | |
| 97 | − | date: "2026-10-04", | |
| 98 | − | title: "Rename a workspace", | |
| 99 | − | about: "Old addresses redirect for 90 days, so links and clones keep working.", | |
| 100 | − | href: "/{workspace}/-/settings", | |
| 101 | − | }, | |
| 102 | − | { | |
| 103 | − | date: "2026-10-04", | |
| 104 | − | title: "Workspace names and icons", | |
| 105 | − | about: "Give a workspace a display name and an icon of its own.", | |
| 106 | − | href: "/{workspace}/-/settings", | |
| 107 | − | }, | |
| 108 | − | ]; | |
| 109 | − | ||
| 110 | − | /** An entry's link, with the workspace filled in; null when it needs one and there is none. */ | |
| 111 | − | export function changelogHref(entry: ChangelogEntry, workspace: string | null): string | null { | |
| 112 | − | if (!entry.href.includes("{workspace}")) return entry.href; | |
| 113 | − | return workspace ? entry.href.replaceAll("{workspace}", workspace) : null; | |
| 114 | − | } |
| 43 | 43 | // An invite link is the front door: the marketing frame, like /register. | |
| 44 | 44 | if (path.startsWith("/invite/")) return false; | |
| 45 | 45 | if (path.startsWith("/policies/")) return false; | |
| 46 | + | // Your settings pages, like /settings: a visitor is sent to sign in. | |
| 47 | + | if (path.startsWith("/settings/")) return false; | |
| 46 | 48 | return true; | |
| 47 | 49 | } | |
| 48 | 50 |
| 34 | 34 | }; | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | /** A person's security log, newest first, for its own settings page. */ | |
| 38 | + | export async function loadSecurityLog(user: User): Promise<SecurityEvent[]> { | |
| 39 | + | const log = await accounts.securityLog(user).catch(() => null); | |
| 40 | + | return log?.ok ? log.value : []; | |
| 41 | + | } | |
| 42 | + | ||
| 37 | 43 | /** The proof a change carries: this session, and the password if it was just typed. */ | |
| 38 | 44 | function proof(request: Request, form: FormData): Reauth { | |
| 39 | 45 | const password = String(form.get("password") ?? ""); |
| 9 | 9 | inviteFor, | |
| 10 | 10 | inviteLink, | |
| 11 | 11 | inviteState, | |
| 12 | + | landingFor, | |
| 12 | 13 | looksAutomated, | |
| 13 | 14 | moreInvitesMailto, | |
| 14 | 15 | remainingLine, | |
| 15 | 16 | signUpCopy, | |
| 17 | + | suggestUsername, | |
| 18 | + | welcomeCookie, | |
| 19 | + | clearWelcome, | |
| 20 | + | welcomes, | |
| 16 | 21 | } from "./invites.ts"; | |
| 17 | 22 | ||
| 18 | 23 | const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk"; | |
| 73 | 78 | assert.equal(looksAutomated(form({}), now), false); | |
| 74 | 79 | assert.equal(looksAutomated(form({ website: " " }), now), false); | |
| 75 | 80 | }); | |
| 81 | + | ||
| 82 | + | test("a username is suggested from the invited address", () => { | |
| 83 | + | assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace"); | |
| 84 | + | assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton"); | |
| 85 | + | assert.equal(suggestUsername("--x--@example.com"), "x"); | |
| 86 | + | assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38)); | |
| 87 | + | assert.equal(suggestUsername("...@example.com"), ""); | |
| 88 | + | assert.equal(suggestUsername(null), ""); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("an invite lands in its workspace, else its repository", () => { | |
| 92 | + | assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io"); | |
| 93 | + | assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t"); | |
| 94 | + | assert.equal(landingFor({ workspace: null, repository: null }), null); | |
| 95 | + | }); | |
| 96 | + | ||
| 97 | + | test("the welcome is for one place, and ends", () => { | |
| 98 | + | const set = welcomeCookie("flagon-io/g1t", true); | |
| 99 | + | assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/); | |
| 100 | + | const header = `a=1; ${set.split(";")[0]}; b=2`; | |
| 101 | + | assert.equal(welcomes(header, "flagon-io/g1t"), true); | |
| 102 | + | assert.equal(welcomes(header, "flagon-io"), false); | |
| 103 | + | assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true); | |
| 104 | + | assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false); | |
| 105 | + | assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false); | |
| 106 | + | assert.equal(welcomes(null, "flagon-io"), false); | |
| 107 | + | assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/); | |
| 108 | + | }); |
| 88 | 88 | const started = Number(form.get("started")); | |
| 89 | 89 | return Number.isFinite(started) && started > 0 && now - started < 1500; | |
| 90 | 90 | } | |
| 91 | + | ||
| 92 | + | /** | |
| 93 | + | * A username to offer someone signing up with `email`: its local part, as | |
| 94 | + | * usernames are written (lowercase letters, digits and single hyphens, up | |
| 95 | + | * to 39). Empty when nothing usable is left. Identity checks it is free. | |
| 96 | + | */ | |
| 97 | + | export function suggestUsername(email: string | null | undefined): string { | |
| 98 | + | const local = (email ?? "").split("@")[0]?.split("+")[0] ?? ""; | |
| 99 | + | return local | |
| 100 | + | .toLowerCase() | |
| 101 | + | .replace(/[^a-z0-9]+/g, "-") | |
| 102 | + | .replace(/^-+|-+$/g, "") | |
| 103 | + | .slice(0, 39) | |
| 104 | + | .replace(/-+$/g, ""); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | type Lands = { workspace: { slug: string } | null; repository: { name: string } | null }; | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * Where using an invite lands: the workspace it joins, the repository it | |
| 111 | + | * gives access to, or nowhere in particular. | |
| 112 | + | */ | |
| 113 | + | export function landingFor(invite: Lands): string | null { | |
| 114 | + | if (invite.workspace) return invite.workspace.slug.toLowerCase(); | |
| 115 | + | if (invite.repository) return invite.repository.name.toLowerCase(); | |
| 116 | + | return null; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** What someone who just joined is welcomed into, for one page view. */ | |
| 120 | + | export const WELCOME_COOKIE = "g1t_welcome"; | |
| 121 | + | ||
| 122 | + | const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/; | |
| 123 | + | ||
| 124 | + | /** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */ | |
| 125 | + | export function welcomeCookie(target: string, secure: boolean): string { | |
| 126 | + | return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /** The `Set-Cookie` value that ends the welcome, once it has been shown. */ | |
| 130 | + | export function clearWelcome(secure: boolean): string { | |
| 131 | + | return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /** Whether the request's cookies welcome someone into `target`. */ | |
| 135 | + | export function welcomes(cookieHeader: string | null, target: string): boolean { | |
| 136 | + | for (const part of (cookieHeader ?? "").split(";")) { | |
| 137 | + | const [key, ...rest] = part.trim().split("="); | |
| 138 | + | if (key !== WELCOME_COOKIE) continue; | |
| 139 | + | let value: string; | |
| 140 | + | try { | |
| 141 | + | value = decodeURIComponent(rest.join("=")); | |
| 142 | + | } catch { | |
| 143 | + | return false; | |
| 144 | + | } | |
| 145 | + | return TARGET.test(value) && value === target.toLowerCase(); | |
| 146 | + | } | |
| 147 | + | return false; | |
| 148 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | + | import { OG_RENDER_VERSION } from "@g1t/contracts/og"; | |
| 5 | + | ||
| 4 | 6 | import { DESCRIPTION, cardUrl, excerpt, fingerprint, page } from "./meta.ts"; | |
| 5 | 7 | ||
| 6 | 8 | type Tag = Record<string, string>; | |
| 21 | 23 | assert.equal(meta.get("og:title"), "Pricing"); | |
| 22 | 24 | assert.equal(meta.get("og:url"), "https://g1t.sh/pricing"); | |
| 23 | 25 | assert.equal(meta.get("og:site_name"), "g1t"); | |
| 24 | − | assert.equal(meta.get("og:image"), "https://og.g1t.sh/image?path=%2Fpricing"); | |
| 26 | + | assert.equal(meta.get("og:image"), `https://og.g1t.sh/image?path=%2Fpricing&v=${OG_RENDER_VERSION}`); | |
| 25 | 27 | assert.equal(meta.get("twitter:card"), "summary_large_image"); | |
| 26 | 28 | assert.equal(meta.get("twitter:image"), meta.get("og:image")); | |
| 27 | 29 | }); | |
| 34 | 36 | const meta = tags(page({ location: { pathname: "/acme/web/commits/" }, matches: [project] }, { title: "Commits · acme/web · g1t" })); | |
| 35 | 37 | assert.equal(meta.get("description"), "The storefront"); | |
| 36 | 38 | assert.equal(meta.get("og:url"), "https://g1t.sh/acme/web/commits"); | |
| 37 | − | assert.match(meta.get("og:image") ?? "", /\?path=%2Facme%2Fweb%2Fcommits&v=[0-9a-z]+$/); | |
| 39 | + | assert.match(meta.get("og:image") ?? "", new RegExp(`\\?path=%2Facme%2Fweb%2Fcommits&v=${OG_RENDER_VERSION}\\.[0-9a-z]+$`)); | |
| 38 | 40 | }); | |
| 39 | 41 | ||
| 40 | 42 | test("with nothing particular to say, a page says what g1t is", () => { | |
| 45 | 47 | test("a card's version changes with what it shows", () => { | |
| 46 | 48 | assert.equal(fingerprint(["a", "open"]), fingerprint(["a", "open"])); | |
| 47 | 49 | assert.notEqual(fingerprint(["a", "open"]), fingerprint(["a", "merged"])); | |
| 48 | − | assert.equal(cardUrl("/", undefined), "https://og.g1t.sh/image?path=%2F"); | |
| 50 | + | assert.equal(cardUrl("/", undefined), `https://og.g1t.sh/image?path=%2F&v=${OG_RENDER_VERSION}`); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | test("every card address carries the render version, so a new design is a new address", () => { | |
| 54 | + | assert.match(cardUrl("/"), new RegExp(`[?&]v=${OG_RENDER_VERSION}$`)); | |
| 55 | + | assert.match(cardUrl("/acme", ["Acme"]), new RegExp(`[?&]v=${OG_RENDER_VERSION}\\.[0-9a-z]+$`)); | |
| 49 | 56 | }); | |
| 50 | 57 | ||
| 51 | 58 | test("an excerpt is plain text from the start of the Markdown", () => { |
| 8 | 8 | * returns `page(args, …)` rather than a bare title. | |
| 9 | 9 | */ | |
| 10 | 10 | import type { MetaDescriptor } from "react-router"; | |
| 11 | + | import { ogVersion } from "@g1t/contracts/og"; | |
| 11 | 12 | ||
| 12 | 13 | export const SITE = "https://g1t.sh"; | |
| 13 | 14 | export const OG = "https://og.g1t.sh"; | |
| 78 | 79 | return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 81 | − | /** The address of a page's card. */ | |
| 82 | + | /** | |
| 83 | + | * The address of a page's card. Its `v` always carries the cards' render | |
| 84 | + | * version, so a new card design is a new address for og's cache, browsers | |
| 85 | + | * and every site that has fetched the old card; then, when the page has one, | |
| 86 | + | * a fingerprint of what the card shows. | |
| 87 | + | */ | |
| 82 | 88 | export function cardUrl(pathname: string, version?: unknown): string { | |
| 83 | 89 | const url = new URL("/image", OG); | |
| 84 | 90 | url.searchParams.set("path", pathname); | |
| 85 | − | if (version !== undefined) url.searchParams.set("v", fingerprint(version)); | |
| 91 | + | url.searchParams.set("v", ogVersion(version === undefined ? undefined : fingerprint(version))); | |
| 86 | 92 | return url.toString(); | |
| 87 | 93 | } | |
| 88 | 94 |
| 27 | 27 | for (const [path, kind] of cases) assert.equal(missingKind(null, path), kind, path); | |
| 28 | 28 | }); | |
| 29 | 29 | ||
| 30 | − | const KINDS: [MissingKind, string][] = [ | |
| 31 | − | ["project", "project"], | |
| 32 | − | ["issue", "issue"], | |
| 33 | − | ["pull", "pull request"], | |
| 34 | − | ["workspace", "workspace"], | |
| 35 | − | ["page", "page"], | |
| 36 | − | ]; | |
| 30 | + | const KINDS: MissingKind[] = ["project", "issue", "pull", "workspace", "page"]; | |
| 37 | 31 | ||
| 38 | − | test("signed out: sign in to see it, which may be private or missing", () => { | |
| 39 | − | for (const [kind, noun] of KINDS) { | |
| 32 | + | test("signed out: nothing here, which may be private, and sign in if it is yours", () => { | |
| 33 | + | for (const kind of KINDS) { | |
| 40 | 34 | const copy = notFoundCopy(kind, null); | |
| 41 | − | assert.equal(copy.title, `Sign in to see this ${noun}`); | |
| 42 | − | assert.equal(copy.body, "It may be private, or it may not exist."); | |
| 35 | + | assert.equal(copy.title, "Nothing here"); | |
| 36 | + | assert.equal(copy.body, "This page doesn't exist, or it's private. Sign in if it's yours."); | |
| 43 | 37 | assert.equal(copy.signIn, true); | |
| 44 | 38 | assert.equal(copy.signedInAs, null); | |
| 45 | − | assert.equal(copy.askOwner, false); | |
| 46 | 39 | } | |
| 47 | 40 | }); | |
| 48 | 41 | ||
| 49 | 42 | test("signed in: it does not exist or you cannot see it, and who you are", () => { | |
| 50 | − | for (const [kind, noun] of KINDS) { | |
| 43 | + | for (const kind of KINDS) { | |
| 51 | 44 | const copy = notFoundCopy(kind, "ada"); | |
| 52 | − | assert.equal(copy.title, `This ${noun} doesn't exist, or you don't have access to it`); | |
| 45 | + | assert.equal(copy.title, "Nothing here"); | |
| 46 | + | assert.equal(copy.body, "This page doesn't exist, or you don't have access to it."); | |
| 53 | 47 | assert.equal(copy.signIn, false); | |
| 54 | 48 | assert.equal(copy.signedInAs, "ada"); | |
| 55 | − | assert.equal(copy.askOwner, true); | |
| 56 | 49 | } | |
| 57 | 50 | }); | |
| 58 | 51 | ||
| 59 | 52 | test("a missing person is simply no one, signed in or not", () => { | |
| 60 | 53 | for (const viewer of [null, "ada"]) { | |
| 61 | 54 | const copy = notFoundCopy("person", viewer); | |
| 62 | − | assert.equal(copy.title, "No one goes by that name"); | |
| 55 | + | assert.equal(copy.body, "No one on g1t goes by that name."); | |
| 63 | 56 | assert.equal(copy.signIn, false); | |
| 64 | − | assert.equal(copy.askOwner, false); | |
| 65 | 57 | } | |
| 66 | 58 | }); | |
| 67 | 59 |
| 51 | 51 | signedInAs: string | null; | |
| 52 | 52 | /** Offer to sign in and come back. */ | |
| 53 | 53 | signIn: boolean; | |
| 54 | − | /** Tell them to ask a workspace owner for access. */ | |
| 55 | − | askOwner: boolean; | |
| 56 | 54 | }; | |
| 57 | 55 | ||
| 58 | − | /** What the page says for this kind of thing, to whoever is looking. */ | |
| 56 | + | /** | |
| 57 | + | * What the page says to whoever is looking. Signed out, it says that the | |
| 58 | + | * page may be private, and to sign in if it is yours; signed in, that it | |
| 59 | + | * does not exist or they cannot reach it. Never which of the two. | |
| 60 | + | */ | |
| 59 | 61 | export function notFoundCopy(kind: MissingKind, username: string | null | undefined): NotFoundCopy { | |
| 60 | 62 | // People are public: a name either belongs to someone or it does not. | |
| 61 | 63 | if (kind === "person") { | |
| 62 | − | return { | |
| 63 | − | title: "No one goes by that name", | |
| 64 | − | body: "Check the spelling, or search for them.", | |
| 65 | − | signedInAs: null, | |
| 66 | − | signIn: false, | |
| 67 | − | askOwner: false, | |
| 68 | − | }; | |
| 64 | + | return { title: "Nothing here", body: "No one on g1t goes by that name.", signedInAs: null, signIn: false }; | |
| 69 | 65 | } | |
| 70 | − | const noun = NOUN[kind]; | |
| 71 | 66 | if (!username) { | |
| 72 | 67 | return { | |
| 73 | − | title: `Sign in to see this ${noun}`, | |
| 74 | − | body: "It may be private, or it may not exist.", | |
| 68 | + | title: "Nothing here", | |
| 69 | + | body: "This page doesn't exist, or it's private. Sign in if it's yours.", | |
| 75 | 70 | signedInAs: null, | |
| 76 | 71 | signIn: true, | |
| 77 | − | askOwner: false, | |
| 78 | 72 | }; | |
| 79 | 73 | } | |
| 80 | 74 | return { | |
| 81 | − | title: `This ${noun} doesn't exist, or you don't have access to it`, | |
| 82 | − | body: "", | |
| 75 | + | title: "Nothing here", | |
| 76 | + | body: "This page doesn't exist, or you don't have access to it.", | |
| 83 | 77 | signedInAs: username, | |
| 84 | 78 | signIn: false, | |
| 85 | − | askOwner: true, | |
| 86 | 79 | }; | |
| 87 | 80 | } |
| 53 | 53 | // Someone a repository is shared with can use it without a workspace. | |
| 54 | 54 | (viewer.grants ?? []).length === 0 && | |
| 55 | 55 | !BEFORE_WORKSPACE.includes(pathname) && | |
| 56 | + | !pathname.startsWith("/settings/") && | |
| 56 | 57 | // An invite to a workspace is how someone without one gets one, and an | |
| 57 | 58 | // invitation to a repository is answered before anything else. | |
| 58 | 59 | !pathname.startsWith("/invite/") && |
| 1 | − | /** | |
| 2 | − | * The checks behind /status and the footer's dot. Each is one cheap call, | |
| 3 | − | * the same a visitor's request would make, with a short timeout, all at | |
| 4 | − | * once. The report is kept in the data centre's cache for a minute, so | |
| 5 | − | * however many people look, each part is checked at most once a minute | |
| 6 | − | * per data centre. | |
| 7 | − | * | |
| 8 | − | * The public hosts (api, mcp, docs, models, g1t.page) are fetched over the | |
| 9 | − | * internet, as their users reach them; the rest through service bindings. | |
| 10 | − | */ | |
| 11 | − | import { billing, identity, repos } from "./services.server"; | |
| 12 | − | import { type ComponentKey, type ProbeResult, type StatusReport, report } from "./status"; | |
| 13 | − | ||
| 14 | − | /** No check waits longer than this. */ | |
| 15 | − | const TIMEOUT_MS = 3000; | |
| 16 | − | /** How long one report is served before the parts are checked again. */ | |
| 17 | − | const CACHE_SECONDS = 60; | |
| 18 | − | /** Where the report is kept: a key in the cache, never a page anyone fetches. */ | |
| 19 | − | const CACHE_KEY = "https://g1t.sh/__status/report/v1"; | |
| 20 | − | /** A public repository that has always existed, to look up. */ | |
| 21 | − | const PROBE_REPO = { namespace: "flagon-io", name: "g1t" }; | |
| 22 | − | /** A session token that cannot exist: the lookup answers "nobody". */ | |
| 23 | − | const NO_SESSION = "0".repeat(64); | |
| 24 | − | ||
| 25 | − | class Timeout extends Error {} | |
| 26 | − | ||
| 27 | − | async function timed(run: (signal: AbortSignal) => Promise<boolean | string>): Promise<ProbeResult> { | |
| 28 | − | const started = Date.now(); | |
| 29 | − | const controller = new AbortController(); | |
| 30 | − | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 31 | − | const deadline = new Promise<never>((_, reject) => { | |
| 32 | − | timer = setTimeout(() => { | |
| 33 | − | controller.abort(); | |
| 34 | − | reject(new Timeout()); | |
| 35 | − | }, TIMEOUT_MS); | |
| 36 | − | }); | |
| 37 | − | try { | |
| 38 | − | const outcome = await Promise.race([run(controller.signal), deadline]); | |
| 39 | − | const ms = Date.now() - started; | |
| 40 | − | return outcome === true ? { ok: true, ms } : { ok: false, ms, error: typeof outcome === "string" ? outcome : "unexpected answer" }; | |
| 41 | − | } catch (error) { | |
| 42 | − | const ms = Date.now() - started; | |
| 43 | − | return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" }; | |
| 44 | − | } finally { | |
| 45 | − | clearTimeout(timer); | |
| 46 | − | } | |
| 47 | − | } | |
| 48 | − | ||
| 49 | − | /** A public address answering 2xx. The body is not read. */ | |
| 50 | − | function reachable(url: string, headers: Record<string, string> = {}) { | |
| 51 | − | return timed(async (signal) => { | |
| 52 | − | const response = await fetch(url, { | |
| 53 | − | signal, | |
| 54 | − | redirect: "manual", | |
| 55 | − | headers: { "user-agent": "g1t-status (+https://g1t.sh/status)", ...headers }, | |
| 56 | − | }); | |
| 57 | − | await response.body?.cancel().catch(() => undefined); | |
| 58 | − | return response.ok || `HTTP ${response.status}`; | |
| 59 | − | }); | |
| 60 | − | } | |
| 61 | − | ||
| 62 | − | async function probeAll(): Promise<Partial<Record<ComponentKey, ProbeResult | null>>> { | |
| 63 | − | const checks: [ComponentKey, Promise<ProbeResult> | null][] = [ | |
| 64 | − | // Every page asks identity who is signed in; a token nobody holds | |
| 65 | − | // answers "nobody" after a database read. | |
| 66 | − | ["site", timed(async () => (await identity.userForSession(NO_SESSION), true))], | |
| 67 | − | ["api", reachable("https://api.g1t.sh/")], | |
| 68 | − | // A lookup answers, found or not, once the repository service and its | |
| 69 | − | // database are up. | |
| 70 | − | ["git", timed(async () => (await repos.get(PROBE_REPO, null), true))], | |
| 71 | − | ["mcp", reachable("https://mcp.g1t.sh/", { accept: "application/json" })], | |
| 72 | − | ["docs", reachable("https://docs.g1t.sh/")], | |
| 73 | − | ["deployments", reachable("https://g1t.page/")], | |
| 74 | − | ["agents", reachable("https://models.g1t.sh/")], | |
| 75 | − | // Not checked yet: see COMPONENTS in status.ts. | |
| 76 | − | ["sandboxes", null], | |
| 77 | − | ["billing", timed(async () => Boolean(await billing.prices()) || "no price book")], | |
| 78 | − | ]; | |
| 79 | − | const results = await Promise.all(checks.map(async ([key, check]) => [key, check ? await check : null] as const)); | |
| 80 | − | return Object.fromEntries(results); | |
| 81 | − | } | |
| 82 | − | ||
| 83 | − | function edgeCache(): Cache | null { | |
| 84 | − | // The Workers runtime's own cache, which the DOM types do not know. | |
| 85 | − | const store = (globalThis as { caches?: { default?: Cache } }).caches; | |
| 86 | − | return store?.default ?? null; | |
| 87 | − | } | |
| 88 | − | ||
| 89 | − | /** The current report: from the cache when it is under a minute old, else checked now. */ | |
| 90 | − | export async function currentStatus(): Promise<StatusReport> { | |
| 91 | − | const cache = edgeCache(); | |
| 92 | − | if (cache) { | |
| 93 | − | try { | |
| 94 | − | const hit = await cache.match(CACHE_KEY); | |
| 95 | − | if (hit) return (await hit.json()) as StatusReport; | |
| 96 | − | } catch { | |
| 97 | − | // A cache that fails is only slower. | |
| 98 | − | } | |
| 99 | − | } | |
| 100 | − | const fresh = report(await probeAll(), new Date()); | |
| 101 | − | if (cache) { | |
| 102 | − | try { | |
| 103 | − | await cache.put( | |
| 104 | − | CACHE_KEY, | |
| 105 | − | Response.json(fresh, { headers: { "cache-control": `public, max-age=${CACHE_SECONDS}` } }), | |
| 106 | − | ); | |
| 107 | − | } catch { | |
| 108 | − | // Checked again next time. | |
| 109 | − | } | |
| 110 | − | } | |
| 111 | − | return fresh; | |
| 112 | − | } | |
| 113 | − | ||
| 114 | − | /** Headers for a response carrying the report: browsers and the edge may keep it a minute. */ | |
| 115 | − | export const STATUS_HEADERS = { "cache-control": `public, max-age=${CACHE_SECONDS}` }; |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { | |
| 5 | − | COMPONENTS, | |
| 6 | − | type ComponentStatus, | |
| 7 | − | type Incident, | |
| 8 | − | classify, | |
| 9 | − | componentStatus, | |
| 10 | − | dotClass, | |
| 11 | − | recentIncidents, | |
| 12 | − | report, | |
| 13 | − | summarize, | |
| 14 | − | } from "./status.ts"; | |
| 4 | + | import { STATUS_JSON_URL, dotClass, statusTitle } from "./status.ts"; | |
| 15 | 5 | ||
| 16 | − | const info = (key: string) => COMPONENTS.find((c) => c.key === key)!; | |
| 17 | − | const row = (key: string, state: ComponentStatus["state"]): ComponentStatus => ({ | |
| 18 | − | ...componentStatus(info(key), null), | |
| 19 | − | state, | |
| 6 | + | test("the status page lives on its own host", () => { | |
| 7 | + | assert.equal(STATUS_JSON_URL, "https://status.g1t.sh/status.json"); | |
| 20 | 8 | }); | |
| 21 | 9 | ||
| 22 | − | test("a quick answer is up, a slow one degraded, a failure down", () => { | |
| 23 | − | assert.deepEqual(classify({ ok: true, ms: 84.4 }), { state: "up", detail: "Answered in 84 ms" }); | |
| 24 | − | assert.equal(classify({ ok: true, ms: 2400 }).state, "degraded"); | |
| 25 | − | assert.deepEqual(classify({ ok: false, ms: 3000, error: "timed out" }), { state: "down", detail: "Failed: timed out" }); | |
| 26 | − | assert.equal(classify({ ok: false, ms: 10 }).detail, "Failed: no answer"); | |
| 27 | − | }); | |
| 28 | − | ||
| 29 | − | test("a part with no check is never shown as working", () => { | |
| 30 | − | assert.deepEqual(classify(null), { state: "unmonitored", detail: "Not monitored yet" }); | |
| 31 | − | const sandboxes = componentStatus(info("sandboxes"), null); | |
| 32 | − | assert.equal(sandboxes.state, "unmonitored"); | |
| 33 | − | assert.equal(sandboxes.latency_ms, null); | |
| 34 | − | }); | |
| 35 | − | ||
| 36 | − | test("everything checked and answering is all clear, whatever is unmonitored", () => { | |
| 37 | − | const overall = summarize([row("site", "up"), row("api", "up"), row("sandboxes", "unmonitored")]); | |
| 38 | − | assert.deepEqual(overall, { state: "up", line: "All monitored systems are working" }); | |
| 39 | − | }); | |
| 40 | − | ||
| 41 | − | test("a core part down is a major outage", () => { | |
| 42 | − | const overall = summarize([row("site", "up"), row("git", "down"), row("docs", "up")]); | |
| 43 | − | assert.equal(overall.state, "down"); | |
| 44 | − | assert.equal(overall.line, "Major outage: Git and repositories"); | |
| 45 | − | }); | |
| 46 | − | ||
| 47 | − | test("a non-core part down, or anything slow, is degraded", () => { | |
| 48 | − | assert.equal(summarize([row("site", "up"), row("docs", "down")]).state, "degraded"); | |
| 49 | − | assert.equal(summarize([row("site", "degraded"), row("docs", "up")]).state, "degraded"); | |
| 50 | − | assert.equal( | |
| 51 | − | summarize([row("site", "up"), row("docs", "down"), row("billing", "degraded")]).line, | |
| 52 | − | "Some systems are having trouble: Documentation, Billing", | |
| 53 | − | ); | |
| 54 | − | }); | |
| 55 | − | ||
| 56 | − | test("down outranks degraded", () => { | |
| 57 | − | assert.equal(summarize([row("docs", "degraded"), row("api", "down")]).state, "down"); | |
| 58 | − | }); | |
| 59 | − | ||
| 60 | − | const incident = (extra: Partial<Incident>): Incident => ({ | |
| 61 | − | id: "inc_1", | |
| 62 | − | title: "Pushes are slow", | |
| 63 | − | impact: "degraded", | |
| 64 | − | components: ["git"], | |
| 65 | − | started_at: "2026-10-05T10:00:00Z", | |
| 66 | − | resolved_at: null, | |
| 67 | − | updates: [], | |
| 68 | − | ...extra, | |
| 69 | − | }); | |
| 70 | − | ||
| 71 | − | test("an open incident lowers the line even when checks pass", () => { | |
| 72 | − | const overall = summarize([row("site", "up"), row("git", "up")], [incident({})]); | |
| 73 | − | assert.deepEqual(overall, { state: "degraded", line: "Investigating: Pushes are slow" }); | |
| 74 | − | assert.equal(summarize([row("site", "up")], [incident({ impact: "down" })]).state, "down"); | |
| 75 | − | }); | |
| 76 | − | ||
| 77 | − | test("a resolved incident does not", () => { | |
| 78 | − | const overall = summarize([row("site", "up")], [incident({ resolved_at: "2026-10-05T11:00:00Z" })]); | |
| 79 | − | assert.equal(overall.state, "up"); | |
| 80 | − | }); | |
| 81 | − | ||
| 82 | − | test("nothing checked and nothing open is unknown, not green", () => { | |
| 83 | − | assert.equal(summarize([row("sandboxes", "unmonitored")]).state, "unknown"); | |
| 84 | − | assert.equal(dotClass("unknown"), "bg-faint"); | |
| 85 | − | }); | |
| 86 | − | ||
| 87 | − | test("old resolved incidents fall off the page; open ones never do", () => { | |
| 88 | − | const now = new Date("2026-10-05T00:00:00Z"); | |
| 89 | − | const list = [ | |
| 90 | − | incident({ id: "old", resolved_at: "2026-06-01T00:00:00Z" }), | |
| 91 | − | incident({ id: "recent", resolved_at: "2026-09-20T00:00:00Z" }), | |
| 92 | − | incident({ id: "open", started_at: "2026-01-01T00:00:00Z" }), | |
| 93 | − | ]; | |
| 94 | − | assert.deepEqual( | |
| 95 | − | recentIncidents(list, now).map((i) => i.id), | |
| 96 | − | ["recent", "open"], | |
| 97 | − | ); | |
| 10 | + | test("the short line is the report's title, else words for its state", () => { | |
| 11 | + | assert.equal(statusTitle({ overall: { state: "up", title: "All systems normal", line: "" } }), "All systems normal"); | |
| 12 | + | assert.equal(statusTitle({ overall: { state: "down", title: "", line: "" } }), "Major outage"); | |
| 13 | + | assert.equal(statusTitle(null), null); | |
| 98 | 14 | }); | |
| 99 | 15 | ||
| 100 | − | test("the report covers every part, in order, with the overall line from the same data", () => { | |
| 101 | − | const now = new Date("2026-10-05T12:00:00Z"); | |
| 102 | − | const r = report({ site: { ok: true, ms: 40 }, api: { ok: false, ms: 3000, error: "timed out" } }, now, []); | |
| 103 | − | assert.deepEqual( | |
| 104 | − | r.components.map((c) => c.key), | |
| 105 | − | COMPONENTS.map((c) => c.key), | |
| 106 | − | ); | |
| 107 | − | assert.equal(r.components.find((c) => c.key === "docs")?.state, "unmonitored"); | |
| 108 | − | assert.equal(r.overall.state, "down"); | |
| 109 | − | assert.equal(r.checked_at, "2026-10-05T12:00:00.000Z"); | |
| 110 | − | }); | |
| 111 | − | ||
| 112 | 16 | test("each state has its own dot", () => { | |
| 113 | 17 | assert.equal(dotClass("up"), "bg-accent"); | |
| 114 | 18 | assert.equal(dotClass("degraded"), "bg-warn"); | |
| 115 | 19 | assert.equal(dotClass("down"), "bg-danger"); | |
| 116 | − | assert.equal(dotClass("unmonitored"), "bg-faint"); | |
| 20 | + | assert.equal(dotClass("unknown"), "bg-faint"); | |
| 117 | 21 | assert.equal(dotClass(null), "bg-faint"); | |
| 118 | 22 | }); |
| 1 | 1 | /** | |
| 2 | − | * What /status shows and the footer's dot is coloured by: each public part | |
| 3 | − | * of g1t, whether it answered a quick check, and one line for the whole. | |
| 4 | − | * | |
| 5 | − | * The checks themselves run on the server (status.server.ts). This module | |
| 6 | − | * only says what a check's result means, so it can be tested on its own. | |
| 7 | − | * Fields are snake_case: /status.json is public, like the API. | |
| 8 | − | */ | |
| 9 | − | ||
| 10 | − | export type ComponentState = "up" | "degraded" | "down" | "unmonitored"; | |
| 11 | − | export type OverallState = "up" | "degraded" | "down" | "unknown"; | |
| 12 | − | ||
| 13 | − | export type ComponentKey = | |
| 14 | − | | "site" | |
| 15 | − | | "api" | |
| 16 | − | | "git" | |
| 17 | − | | "mcp" | |
| 18 | − | | "docs" | |
| 19 | − | | "deployments" | |
| 20 | − | | "agents" | |
| 21 | − | | "sandboxes" | |
| 22 | − | | "billing"; | |
| 23 | − | ||
| 24 | − | export type ComponentInfo = { | |
| 25 | − | key: ComponentKey; | |
| 26 | − | name: string; | |
| 27 | − | /** Where people meet it. */ | |
| 28 | − | address: string; | |
| 29 | − | /** What the check actually looks at, said plainly on the page. */ | |
| 30 | − | checks: string; | |
| 31 | − | /** Whether g1t as a whole is down when this is. */ | |
| 32 | − | core: boolean; | |
| 33 | − | }; | |
| 34 | − | ||
| 35 | − | /** The public parts of g1t, in the order the page lists them. */ | |
| 36 | − | export const COMPONENTS: ComponentInfo[] = [ | |
| 37 | − | { | |
| 38 | − | key: "site", | |
| 39 | − | name: "Website and sign-in", | |
| 40 | − | address: "g1t.sh", | |
| 41 | − | checks: "The account service every page asks who is signed in.", | |
| 42 | − | core: true, | |
| 43 | − | }, | |
| 44 | − | { key: "api", name: "API", address: "api.g1t.sh", checks: "The API's front page, over the public internet.", core: true }, | |
| 45 | − | { | |
| 46 | − | key: "git", | |
| 47 | − | name: "Git and repositories", | |
| 48 | − | address: "g1t.sh/<owner>/<repo>.git", | |
| 49 | − | checks: "The repository service looking up a public repository. Clones and pushes themselves are not exercised.", | |
| 50 | − | core: true, | |
| 51 | − | }, | |
| 52 | − | { key: "mcp", name: "MCP server", address: "mcp.g1t.sh", checks: "The MCP server's description, over the public internet.", core: false }, | |
| 53 | − | { key: "docs", name: "Documentation", address: "docs.g1t.sh", checks: "The documentation's front page.", core: false }, | |
| 54 | − | { | |
| 55 | − | key: "deployments", | |
| 56 | − | name: "Deployments", | |
| 57 | − | address: "*.g1t.page", | |
| 58 | − | checks: "g1t.page answering. Each deployed app is not checked one by one.", | |
| 59 | − | core: false, | |
| 60 | − | }, | |
| 61 | − | { | |
| 62 | − | key: "agents", | |
| 63 | − | name: "Agents' model proxy", | |
| 64 | − | address: "models.g1t.sh", | |
| 65 | − | checks: "The proxy g1t's agents reach their model through. The model providers behind it are not checked.", | |
| 66 | − | core: false, | |
| 67 | − | }, | |
| 68 | − | { | |
| 69 | − | key: "sandboxes", | |
| 70 | − | name: "Sandboxes", | |
| 71 | − | address: "Agents, checks, workflows and builds", | |
| 72 | − | checks: "Starting a sandbox costs money and takes seconds, so it is not checked from here yet.", | |
| 73 | − | core: false, | |
| 74 | − | }, | |
| 75 | − | { | |
| 76 | − | key: "billing", | |
| 77 | − | name: "Billing", | |
| 78 | − | address: "g1t.sh/<workspace>/-/billing", | |
| 79 | − | checks: "The billing service reading its price book. Stripe itself is not checked.", | |
| 80 | − | core: false, | |
| 81 | − | }, | |
| 82 | − | ]; | |
| 83 | − | ||
| 84 | − | /** A check's raw outcome. */ | |
| 85 | − | export type ProbeResult = { | |
| 86 | − | ok: boolean; | |
| 87 | − | /** How long it took, in milliseconds. */ | |
| 88 | − | ms: number; | |
| 89 | − | /** Why it failed, in a few words: "timed out", "HTTP 502". */ | |
| 90 | − | error?: string; | |
| 91 | − | }; | |
| 92 | − | ||
| 93 | − | /** Slower than this, a part that answered counts as degraded. */ | |
| 94 | − | export const SLOW_MS = 1500; | |
| 95 | − | ||
| 96 | − | export type ComponentStatus = { | |
| 97 | − | key: ComponentKey; | |
| 98 | − | name: string; | |
| 99 | − | address: string; | |
| 100 | − | checks: string; | |
| 101 | − | state: ComponentState; | |
| 102 | − | /** What was seen, for the page: "Answered in 84 ms", "Timed out". */ | |
| 103 | − | detail: string; | |
| 104 | − | latency_ms: number | null; | |
| 105 | − | }; | |
| 106 | − | ||
| 107 | − | /** What a check's result means for its part. `null` is a part with no check. */ | |
| 108 | − | export function classify(result: ProbeResult | null, slowMs = SLOW_MS): { state: ComponentState; detail: string } { | |
| 109 | − | if (!result) return { state: "unmonitored", detail: "Not monitored yet" }; | |
| 110 | − | if (!result.ok) { | |
| 111 | − | const why = result.error ?? "no answer"; | |
| 112 | − | return { state: "down", detail: `Failed: ${why}` }; | |
| 113 | − | } | |
| 114 | − | const ms = Math.round(result.ms); | |
| 115 | − | if (ms > slowMs) return { state: "degraded", detail: `Slow: answered in ${ms} ms` }; | |
| 116 | − | return { state: "up", detail: `Answered in ${ms} ms` }; | |
| 117 | − | } | |
| 118 | − | ||
| 119 | − | /** One component's row, from its check. */ | |
| 120 | − | export function componentStatus(info: ComponentInfo, result: ProbeResult | null, slowMs = SLOW_MS): ComponentStatus { | |
| 121 | − | const { state, detail } = classify(result, slowMs); | |
| 122 | − | return { | |
| 123 | − | key: info.key, | |
| 124 | − | name: info.name, | |
| 125 | − | address: info.address, | |
| 126 | − | checks: info.checks, | |
| 127 | − | state, | |
| 128 | − | detail, | |
| 129 | − | latency_ms: result ? Math.round(result.ms) : null, | |
| 130 | − | }; | |
| 131 | − | } | |
| 132 | − | ||
| 133 | − | export type IncidentUpdate = { at: string; text: string }; | |
| 134 | − | ||
| 135 | − | export type Incident = { | |
| 136 | − | id: string; | |
| 137 | − | title: string; | |
| 138 | − | /** How bad it is while open. */ | |
| 139 | − | impact: "degraded" | "down"; | |
| 140 | − | components: ComponentKey[]; | |
| 141 | − | started_at: string; | |
| 142 | − | /** Null while it is still going on. */ | |
| 143 | − | resolved_at: string | null; | |
| 144 | − | /** Newest first. */ | |
| 145 | − | updates: IncidentUpdate[]; | |
| 146 | − | }; | |
| 147 | − | ||
| 148 | − | /** | |
| 149 | − | * Incidents, newest first. Written by hand, in code, for now: add one when | |
| 150 | − | * something breaks, with updates as it is fixed, and set `resolved_at` once | |
| 151 | − | * it is. The page shows the last 90 days. | |
| 2 | + | * g1t's status lives at status.g1t.sh (apps/status), a Worker of its own | |
| 3 | + | * that stays up when the site does not. The site only links to it, and | |
| 4 | + | * reads its JSON for the dot in the account menu and the footer. | |
| 152 | 5 | */ | |
| 153 | − | export const INCIDENTS: Incident[] = []; | |
| 154 | − | ||
| 155 | − | /** How far back the page lists incidents. */ | |
| 156 | − | export const INCIDENT_DAYS = 90; | |
| 157 | − | ||
| 158 | − | export type Overall = { state: OverallState; line: string }; | |
| 159 | − | ||
| 160 | − | const RANK: Record<OverallState, number> = { unknown: 0, up: 1, degraded: 2, down: 3 }; | |
| 161 | − | ||
| 162 | − | function worse(a: OverallState, b: OverallState): OverallState { | |
| 163 | − | return RANK[b] > RANK[a] ? b : a; | |
| 164 | − | } | |
| 6 | + | import type { StatusOverallState, StatusReport } from "@g1t/contracts"; | |
| 165 | 7 | ||
| 166 | − | /** | |
| 167 | − | * One line for the whole: down when a core part is down, degraded when any | |
| 168 | − | * part is down or slow or an incident is open, up when every checked part | |
| 169 | − | * answered. Parts that are not checked never make it look better or worse. | |
| 170 | − | */ | |
| 171 | − | export function summarize(components: ComponentStatus[], incidents: Incident[] = []): Overall { | |
| 172 | − | const core = new Set(COMPONENTS.filter((c) => c.core).map((c) => c.key)); | |
| 173 | − | const monitored = components.filter((c) => c.state !== "unmonitored"); | |
| 174 | − | const open = incidents.filter((incident) => incident.resolved_at == null); | |
| 175 | − | if (monitored.length === 0 && open.length === 0) { | |
| 176 | − | return { state: "unknown", line: "Status is not available right now" }; | |
| 177 | − | } | |
| 178 | − | let state: OverallState = monitored.length > 0 ? "up" : "unknown"; | |
| 179 | − | for (const c of monitored) { | |
| 180 | − | if (c.state === "down") state = worse(state, core.has(c.key) ? "down" : "degraded"); | |
| 181 | − | else if (c.state === "degraded") state = worse(state, "degraded"); | |
| 182 | − | } | |
| 183 | − | for (const incident of open) state = worse(state, incident.impact); | |
| 8 | + | export type { StatusOverallState as OverallState, StatusReport }; | |
| 184 | 9 | ||
| 185 | − | const failing = monitored.filter((c) => c.state === "down" || c.state === "degraded").map((c) => c.name); | |
| 186 | − | if (state === "up") return { state, line: "All monitored systems are working" }; | |
| 187 | − | if (state === "down") { | |
| 188 | − | return { state, line: failing.length ? `Major outage: ${failing.join(", ")}` : "Major outage" }; | |
| 189 | − | } | |
| 190 | − | if (state === "degraded") { | |
| 191 | − | if (failing.length) return { state, line: `Some systems are having trouble: ${failing.join(", ")}` }; | |
| 192 | − | return { state, line: open[0] ? `Investigating: ${open[0].title}` : "Some systems are having trouble" }; | |
| 193 | − | } | |
| 194 | − | return { state, line: "Status is not available right now" }; | |
| 195 | − | } | |
| 10 | + | /** The status page. */ | |
| 11 | + | export const STATUS_URL = "https://status.g1t.sh"; | |
| 12 | + | /** Its report, as JSON, readable from any origin. */ | |
| 13 | + | export const STATUS_JSON_URL = `${STATUS_URL}/status.json`; | |
| 196 | 14 | ||
| 197 | − | export type StatusReport = { | |
| 198 | − | checked_at: string; | |
| 199 | − | overall: Overall; | |
| 200 | − | components: ComponentStatus[]; | |
| 201 | − | incidents: Incident[]; | |
| 15 | + | /** Two or three words for the whole, when the report has none. */ | |
| 16 | + | export const STATUS_WORDS: Record<StatusOverallState, string> = { | |
| 17 | + | up: "All systems normal", | |
| 18 | + | degraded: "Partial outage", | |
| 19 | + | down: "Major outage", | |
| 20 | + | maintenance: "Under maintenance", | |
| 21 | + | unknown: "Status unknown", | |
| 202 | 22 | }; | |
| 203 | − | ||
| 204 | − | /** Incidents still open, or resolved within `days` of `now`. */ | |
| 205 | − | export function recentIncidents(incidents: Incident[], now: Date, days = INCIDENT_DAYS): Incident[] { | |
| 206 | − | const since = now.getTime() - days * 24 * 60 * 60 * 1000; | |
| 207 | − | return incidents.filter((incident) => incident.resolved_at == null || Date.parse(incident.resolved_at) >= since); | |
| 208 | − | } | |
| 209 | 23 | ||
| 210 | − | /** The whole report, from each part's check. */ | |
| 211 | − | export function report(results: Partial<Record<ComponentKey, ProbeResult | null>>, now: Date, incidents = INCIDENTS): StatusReport { | |
| 212 | − | const components = COMPONENTS.map((info) => componentStatus(info, results[info.key] ?? null)); | |
| 213 | − | const recent = recentIncidents(incidents, now); | |
| 214 | − | return { checked_at: now.toISOString(), overall: summarize(components, recent), components, incidents: recent }; | |
| 24 | + | /** The short line for the whole: the report's own title, else words for its state. */ | |
| 25 | + | export function statusTitle(report: Pick<StatusReport, "overall"> | null | undefined): string | null { | |
| 26 | + | if (!report?.overall) return null; | |
| 27 | + | return report.overall.title || STATUS_WORDS[report.overall.state] || null; | |
| 215 | 28 | } | |
| 216 | − | ||
| 217 | − | /** Words for a part's state. */ | |
| 218 | − | export const STATE_LABEL: Record<ComponentState, string> = { | |
| 219 | − | up: "Operational", | |
| 220 | − | degraded: "Degraded", | |
| 221 | − | down: "Down", | |
| 222 | − | unmonitored: "Not monitored yet", | |
| 223 | − | }; | |
| 224 | 29 | ||
| 225 | 30 | /** The dot's colour for a state, as a Tailwind background class. */ | |
| 226 | − | export function dotClass(state: ComponentState | OverallState | null | undefined): string { | |
| 31 | + | export function dotClass(state: string | null | undefined): string { | |
| 227 | 32 | switch (state) { | |
| 228 | 33 | case "up": | |
| 229 | 34 | return "bg-accent"; | |
| 231 | 36 | return "bg-warn"; | |
| 232 | 37 | case "down": | |
| 233 | 38 | return "bg-danger"; | |
| 39 | + | case "maintenance": | |
| 40 | + | return "bg-info"; | |
| 234 | 41 | default: | |
| 235 | 42 | return "bg-faint"; | |
| 236 | 43 | } |
| 25 | 25 | type ShouldRevalidateFunctionArgs, | |
| 26 | 26 | useLocation, | |
| 27 | 27 | useParams, | |
| 28 | + | useRouteError, | |
| 28 | 29 | useRouteLoaderData, | |
| 29 | 30 | useSubmit, | |
| 30 | 31 | } from "react-router"; | |
| 373 | 374 | </DropdownMenuItem> | |
| 374 | 375 | <DropdownMenuSeparator /> | |
| 375 | 376 | <DropdownMenuItem asChild> | |
| 376 | − | <Link to="/settings"> | |
| 377 | + | <Link to="/settings/profile"> | |
| 377 | 378 | <Settings /> | |
| 378 | 379 | Settings | |
| 379 | 380 | </Link> | |
| 415 | 416 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 416 | 417 | const user = root?.user; | |
| 417 | 418 | const { pathname } = useLocation(); | |
| 419 | + | // Drawn around the error page too: a 404 keeps the sidebar out of a | |
| 420 | + | // project or workspace the viewer cannot see. | |
| 421 | + | const error = useRouteError(); | |
| 422 | + | const missing = isRouteErrorResponse(error) && error.status === 404; | |
| 418 | 423 | // The billing page shows the full banner itself. | |
| 419 | 424 | const paused = root?.shell?.compute && root.shell.workspace && !pathname.endsWith("/-/billing") && ( | |
| 420 | 425 | <Suspense fallback={null}> | |
| 459 | 464 | </head> | |
| 460 | 465 | <body className="flex min-h-screen flex-col"> | |
| 461 | 466 | {root?.shell && usesAppShell(pathname, user != null) ? ( | |
| 462 | − | <AppShell user={user ?? null} shell={root.shell} banner={banner}> | |
| 467 | + | <AppShell user={user ?? null} shell={root.shell} missing={missing} banner={banner}> | |
| 463 | 468 | {children} | |
| 464 | 469 | </AppShell> | |
| 465 | 470 | ) : ( |
| 21 | 21 | route("auth/github/username", "routes/auth-github-username.tsx"), | |
| 22 | 22 | route("integrations/github/install", "routes/github-install.ts"), | |
| 23 | 23 | route("integrations/github/setup", "routes/github-setup.tsx"), | |
| 24 | − | route("settings", "routes/settings.tsx"), | |
| 24 | + | // Your own settings: one page each, so links can name the one they mean. | |
| 25 | + | // `/settings` goes to the first; an old link's `#anchor` to its page. | |
| 26 | + | route("settings", "routes/settings/layout.tsx", [ | |
| 27 | + | index("routes/settings/index.tsx"), | |
| 28 | + | route("profile", "routes/settings/profile.tsx"), | |
| 29 | + | route("emails", "routes/settings/emails.tsx"), | |
| 30 | + | route("invites", "routes/settings/invites.tsx"), | |
| 31 | + | route("keys", "routes/settings/keys.tsx"), | |
| 32 | + | route("tokens", "routes/settings/tokens.tsx"), | |
| 33 | + | route("github", "routes/settings/github.tsx"), | |
| 34 | + | route("applications", "routes/settings/applications.tsx"), | |
| 35 | + | route("security-log", "routes/settings/security-log.tsx"), | |
| 36 | + | ]), | |
| 37 | + | // The account menu's header: name, primary email and invites left. | |
| 38 | + | route("settings/menu.json", "routes/settings-menu-json.ts"), | |
| 25 | 39 | // People, apart from workspaces: `u` is a reserved name. | |
| 26 | 40 | route("u/:username", "routes/user.tsx"), | |
| 27 | 41 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 35 | 49 | route("policies/:policy", "routes/policy.tsx"), | |
| 36 | 50 | route("security", "routes/security.tsx"), | |
| 37 | 51 | route("support", "routes/support.tsx"), | |
| 52 | + | // Status moved to status.g1t.sh (apps/status); old links go there. | |
| 38 | 53 | route("status", "routes/status.tsx"), | |
| 39 | − | // What /status shows and the footer's dot reads, as JSON. | |
| 40 | 54 | route("status.json", "routes/status-json.ts"), | |
| 41 | 55 | route(".well-known/security.txt", "routes/security-txt.ts"), | |
| 42 | 56 | // A workspace's own pages sit under `-`, which no repository can be named. |
| 40 | 40 | headers.append("set-cookie", startSession(done.signedIn.sessionToken)); | |
| 41 | 41 | throw redirect(safeNext(done.next), { headers }); | |
| 42 | 42 | case "linked": | |
| 43 | − | throw redirect(safeNext(done.next === "/" ? "/settings#github" : done.next), { headers }); | |
| 43 | + | throw redirect(safeNext(done.next === "/" ? "/settings/github" : done.next), { headers }); | |
| 44 | 44 | case "needs_link": | |
| 45 | 45 | headers.append("set-cookie", cookie(PENDING_COOKIE, done.pending, 1800)); | |
| 46 | 46 | throw redirect(`/login?github=link${done.next === "/" ? "" : `&next=${encodeURIComponent(done.next)}`}`, { headers }); |
| 15 | 15 | const url = new URL(request.url); | |
| 16 | 16 | const viewer = getViewer(context); | |
| 17 | 17 | const link = url.searchParams.get("link") === "1"; | |
| 18 | − | if (link && !viewer) throw redirect(`/login?next=${encodeURIComponent("/settings#github")}`); | |
| 18 | + | if (link && !viewer) throw redirect(`/login?next=${encodeURIComponent("/settings/github")}`); | |
| 19 | 19 | if (!link && viewer) throw redirect(nextPath(request)); | |
| 20 | 20 | const invite = url.searchParams.get("invite")?.trim().slice(0, 100) || null; | |
| 21 | 21 | const started = await githubSignIn.start({ | |
| 22 | 22 | purpose: link ? "link" : "sign_in", | |
| 23 | 23 | user: link ? viewer : null, | |
| 24 | 24 | redirectUri: callbackUrl(request), | |
| 25 | − | next: link ? (url.searchParams.has("next") ? nextPath(request) : "/settings#github") : nextPath(request), | |
| 25 | + | next: link ? (url.searchParams.has("next") ? nextPath(request) : "/settings/github") : nextPath(request), | |
| 26 | 26 | inviteCode: invite, | |
| 27 | 27 | }); | |
| 28 | − | if (!started.ok) throw redirect(link ? "/settings#github" : "/login"); | |
| 28 | + | if (!started.ok) throw redirect(link ? "/settings/github" : "/login"); | |
| 29 | 29 | throw redirect(started.value.authorizeUrl, { | |
| 30 | 30 | headers: { "set-cookie": cookie(STATE_COOKIE, started.value.state, 600) }, | |
| 31 | 31 | }); |
| 1 | − | import { CircleAlert, Ticket } from "lucide-react"; | |
| 2 | − | import { Form, Link, data, redirect } from "react-router"; | |
| 1 | + | import { CircleAlert, Lock, Ticket } from "lucide-react"; | |
| 2 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { InvitePreview } from "@g1t/contracts"; | |
| 4 | + | import type { InvitePreview, User } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/invite"; | |
| 7 | 7 | import { page } from "../lib/meta"; | |
| 8 | 8 | import { Mark } from "../components/logo"; | |
| 9 | − | import { Avatar, Button, ButtonLink, ErrorText } from "../components/ui"; | |
| 9 | + | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | + | import { Honeypot } from "../components/honeypot"; | |
| 11 | + | import { Avatar, Button, ButtonLink, ErrorText, Field, Input } from "../components/ui"; | |
| 12 | + | import { githubSignInEnabled } from "../lib/github.server"; | |
| 10 | 13 | import { identity } from "../lib/services.server"; | |
| 11 | − | import { cleanCode } from "../lib/invites"; | |
| 14 | + | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 12 | 15 | import { clientKey } from "../lib/registration.server"; | |
| 13 | − | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; | |
| 16 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; | |
| 17 | + | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| 14 | 18 | ||
| 15 | 19 | export function meta(args: Route.MetaArgs) { | |
| 16 | 20 | return page(args, { | |
| 19 | 23 | }); | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 26 | + | /** | |
| 27 | + | * Where an invite is used, from first click to landing inside: who sent | |
| 28 | + | * it and what it is for, then signing up on this page (or signing in, for | |
| 29 | + | * an address that has an account), and the workspace or repository it | |
| 30 | + | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with | |
| 31 | + | * `?accept=1`, which finishes the job. | |
| 32 | + | */ | |
| 22 | 33 | export async function loader({ request, context, params }: Route.LoaderArgs) { | |
| 23 | 34 | const code = cleanCode(params.code); | |
| 24 | 35 | const viewer = getViewer(context); | |
| 25 | − | const checked = await identity.checkInvite(code, clientKey(request)); | |
| 26 | − | return { | |
| 27 | − | code, | |
| 28 | − | viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null, | |
| 29 | − | invite: checked.ok ? checked.value : null, | |
| 30 | − | error: checked.ok ? null : checked.error.message, | |
| 31 | − | }; | |
| 36 | + | const accepting = new URL(request.url).searchParams.get("accept") === "1"; | |
| 37 | + | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); | |
| 38 | + | const invite = checked.ok ? checked.value : null; | |
| 39 | + | const lands = invite ? landingFor(invite) : null; | |
| 40 | + | ||
| 41 | + | if (viewer && invite) { | |
| 42 | + | // Used by this person already: just made the account with it (through | |
| 43 | + | // GitHub), or an old link opened again. | |
| 44 | + | if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting); | |
| 45 | + | // Back from signing in to accept an invite sent to their address. | |
| 46 | + | if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) { | |
| 47 | + | const accepted = await identity.acceptInvite(viewer, code); | |
| 48 | + | if (accepted.ok) throw landIn(request, invite, [], true); | |
| 49 | + | return { ...base(), acceptError: accepted.error.message }; | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function base() { | |
| 54 | + | return { | |
| 55 | + | code, | |
| 56 | + | invite, | |
| 57 | + | error: checked.ok ? null : checked.error.message, | |
| 58 | + | viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null, | |
| 59 | + | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, | |
| 60 | + | github: false, | |
| 61 | + | suggestion: suggestUsername(invite?.address), | |
| 62 | + | started: Date.now(), | |
| 63 | + | acceptError: null as string | null, | |
| 64 | + | }; | |
| 65 | + | } | |
| 66 | + | const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount; | |
| 67 | + | return { ...base(), github: signingUp ? await githubSignInEnabled() : false }; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** Whether the viewer is in what the invite gives already. */ | |
| 71 | + | function alreadyIn(viewer: User, invite: InvitePreview): boolean { | |
| 72 | + | if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null; | |
| 73 | + | return false; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** | |
| 77 | + | * The redirect into what the invite gave, with the session (when one was | |
| 78 | + | * just made), the welcome for that first view, and the workspace made the | |
| 79 | + | * one the sidebar is about. | |
| 80 | + | */ | |
| 81 | + | function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response { | |
| 82 | + | const secure = new URL(request.url).protocol === "https:"; | |
| 83 | + | const target = landingFor(invite); | |
| 84 | + | const headers = new Headers(); | |
| 85 | + | for (const cookie of cookies) headers.append("set-cookie", cookie); | |
| 86 | + | if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure)); | |
| 87 | + | if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure)); | |
| 88 | + | return redirect(target ? `/${target}` : "/", { headers }); | |
| 32 | 89 | } | |
| 33 | 90 | ||
| 34 | 91 | export async function action({ request, context, params }: Route.ActionArgs) { | |
| 35 | 92 | assertSameOrigin(request); | |
| 93 | + | const code = cleanCode(params.code); | |
| 94 | + | const form = await request.formData(); | |
| 95 | + | const client = clientKey(request); | |
| 96 | + | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) }); | |
| 97 | + | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); | |
| 98 | + | const invite = checked.value; | |
| 99 | + | ||
| 100 | + | if (form.get("intent") === "register") { | |
| 101 | + | if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 }); | |
| 102 | + | // An invite for one address makes the account with that address, | |
| 103 | + | // whatever the form sent. | |
| 104 | + | const email = invite.address ?? String(form.get("email") ?? ""); | |
| 105 | + | const result = await identity.register( | |
| 106 | + | String(form.get("username") ?? ""), | |
| 107 | + | email, | |
| 108 | + | String(form.get("password") ?? ""), | |
| 109 | + | code, | |
| 110 | + | client, | |
| 111 | + | ); | |
| 112 | + | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 113 | + | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); | |
| 114 | + | } | |
| 115 | + | ||
| 36 | 116 | const user = requireUser(context, request); | |
| 37 | − | const result = await identity.acceptInvite(user, cleanCode(params.code)); | |
| 117 | + | const result = await identity.acceptInvite(user, code); | |
| 38 | 118 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 39 | − | throw redirect(`/${result.value}`); | |
| 119 | + | throw landIn(request, invite, [], true); | |
| 40 | 120 | } | |
| 41 | 121 | ||
| 42 | − | function Sender({ invite }: { invite: InvitePreview }) { | |
| 122 | + | /** Who sent it, and the workspace it joins, as faces. */ | |
| 123 | + | function Faces({ invite }: { invite: InvitePreview }) { | |
| 43 | 124 | const from = invite.invitedBy; | |
| 44 | 125 | return ( | |
| 45 | 126 | <div className="flex items-center gap-3"> | |
| 60 | 141 | ); | |
| 61 | 142 | } | |
| 62 | 143 | ||
| 63 | − | function headline(invite: InvitePreview): string { | |
| 64 | − | const from = invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team"; | |
| 65 | − | return invite.workspace ? `${from} invited you to ${invite.workspace.name}` : `${from} invited you to g1t`; | |
| 144 | + | function senderName(invite: InvitePreview): string { | |
| 145 | + | return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team"; | |
| 66 | 146 | } | |
| 67 | 147 | ||
| 148 | + | /** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */ | |
| 149 | + | function Headline({ invite }: { invite: InvitePreview }) { | |
| 150 | + | const from = senderName(invite); | |
| 151 | + | if (invite.workspace) { | |
| 152 | + | return ( | |
| 153 | + | <> | |
| 154 | + | {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t | |
| 155 | + | </> | |
| 156 | + | ); | |
| 157 | + | } | |
| 158 | + | if (invite.repository) { | |
| 159 | + | return ( | |
| 160 | + | <> | |
| 161 | + | {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong> | |
| 162 | + | </> | |
| 163 | + | ); | |
| 164 | + | } | |
| 165 | + | return <>{from} invited you to g1t</>; | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | function about(invite: InvitePreview, signedIn: boolean): string { | |
| 169 | + | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| 170 | + | if (invite.workspace) { | |
| 171 | + | return `g1t is where people and agents ship software together. ${ | |
| 172 | + | signingUp ? "Make your account below and you join" : "Accepting joins you to" | |
| 173 | + | } ${invite.workspace.name} as a member.`; | |
| 174 | + | } | |
| 175 | + | if (invite.repository) { | |
| 176 | + | return `g1t is where people and agents ship software together. ${ | |
| 177 | + | signingUp ? "Make your account below and you get" : "Accepting gives you" | |
| 178 | + | } the ${invite.repository.role} role on ${invite.repository.name}.`; | |
| 179 | + | } | |
| 180 | + | return "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in."; | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /** What accepting is called on its button. */ | |
| 184 | + | function joinLabel(invite: InvitePreview): string { | |
| 185 | + | if (invite.workspace) return `Join ${invite.workspace.name}`; | |
| 186 | + | if (invite.repository) return `Accept access to ${invite.repository.name}`; | |
| 187 | + | return "Accept invite"; | |
| 188 | + | } | |
| 189 | + | ||
| 68 | 190 | const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 69 | 191 | ||
| 192 | + | type Loaded = Route.ComponentProps["loaderData"]; | |
| 193 | + | ||
| 194 | + | /** Signing up, on this page: the address the invite was sent to, a username, a password. */ | |
| 195 | + | function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 196 | + | const invite = loaded.invite!; | |
| 197 | + | const busy = useNavigation().state === "submitting"; | |
| 198 | + | const here = `/invite/${loaded.code}`; | |
| 199 | + | const back = `${here}?accept=1`; | |
| 200 | + | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 201 | + | return ( | |
| 202 | + | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> | |
| 203 | + | <h2 id="sign-up" className="text-base font-semibold"> | |
| 204 | + | Create your account | |
| 205 | + | </h2> | |
| 206 | + | <p className="mt-1 text-sm text-muted"> | |
| 207 | + | {invite.workspace | |
| 208 | + | ? `You land in ${invite.workspace.name} as soon as it is made.` | |
| 209 | + | : invite.repository | |
| 210 | + | ? `You land in ${invite.repository.name} as soon as it is made.` | |
| 211 | + | : "It takes a minute."} | |
| 212 | + | </p> | |
| 213 | + | {loaded.github && ( | |
| 214 | + | <div className="mt-5"> | |
| 215 | + | <ContinueWithGithub href={github} /> | |
| 216 | + | <OrDivider /> | |
| 217 | + | </div> | |
| 218 | + | )} | |
| 219 | + | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> | |
| 220 | + | <input type="hidden" name="intent" value="register" /> | |
| 221 | + | <Honeypot started={loaded.started} /> | |
| 222 | + | {invite.address ? ( | |
| 223 | + | <Field label="Email" hint="Your invite was sent here, so this address is confirmed already."> | |
| 224 | + | <span className="relative block"> | |
| 225 | + | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> | |
| 226 | + | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 227 | + | </span> | |
| 228 | + | </Field> | |
| 229 | + | ) : ( | |
| 230 | + | <Field label="Email" hint="We send a link to confirm it."> | |
| 231 | + | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> | |
| 232 | + | </Field> | |
| 233 | + | )} | |
| 234 | + | <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you."> | |
| 235 | + | <Input | |
| 236 | + | name="username" | |
| 237 | + | autoComplete="username" | |
| 238 | + | required | |
| 239 | + | autoFocus | |
| 240 | + | maxLength={39} | |
| 241 | + | defaultValue={loaded.suggestion} | |
| 242 | + | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 243 | + | /> | |
| 244 | + | </Field> | |
| 245 | + | <Field label="Password" hint="At least 10 characters."> | |
| 246 | + | <Input name="password" type="password" autoComplete="new-password" required minLength={10} /> | |
| 247 | + | </Field> | |
| 248 | + | <ErrorText>{error}</ErrorText> | |
| 249 | + | <div className="pt-1 *:w-full"> | |
| 250 | + | <Button type="submit" disabled={busy}> | |
| 251 | + | {invite.workspace | |
| 252 | + | ? `Create account and join ${invite.workspace.name}` | |
| 253 | + | : invite.repository | |
| 254 | + | ? "Create account and accept" | |
| 255 | + | : "Create account"} | |
| 256 | + | </Button> | |
| 257 | + | </div> | |
| 258 | + | </Form> | |
| 259 | + | <p className="mt-5 text-center text-sm text-muted"> | |
| 260 | + | Already on g1t?{" "} | |
| 261 | + | <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4"> | |
| 262 | + | Sign in to accept | |
| 263 | + | </Link> | |
| 264 | + | </p> | |
| 265 | + | </section> | |
| 266 | + | ); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /** What to do next: join, sign in, sign out, or sign up. */ | |
| 270 | + | function Next({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 271 | + | const invite = loaded.invite!; | |
| 272 | + | const viewer = loaded.viewer; | |
| 273 | + | const here = `/invite/${loaded.code}`; | |
| 274 | + | const back = `${here}?accept=1`; | |
| 275 | + | const joinable = invite.workspace !== null || invite.repository !== null; | |
| 276 | + | ||
| 277 | + | if (viewer) { | |
| 278 | + | const signedInAs = ( | |
| 279 | + | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 280 | + | <Avatar name={viewer.username} image={viewer.avatar} size={20} /> | |
| 281 | + | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 282 | + | </p> | |
| 283 | + | ); | |
| 284 | + | const signOut = (label: string, variant: "primary" | "quiet") => ( | |
| 285 | + | <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}> | |
| 286 | + | <Button type="submit" variant={variant}> | |
| 287 | + | {label} | |
| 288 | + | </Button> | |
| 289 | + | </Form> | |
| 290 | + | ); | |
| 291 | + | if (invite.forViewer === false) { | |
| 292 | + | return ( | |
| 293 | + | <div className="space-y-4"> | |
| 294 | + | {signedInAs} | |
| 295 | + | <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status"> | |
| 296 | + | <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p> | |
| 297 | + | <p className="mt-1 text-muted"> | |
| 298 | + | Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it. | |
| 299 | + | </p> | |
| 300 | + | </div> | |
| 301 | + | <ErrorText>{error}</ErrorText> | |
| 302 | + | <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div> | |
| 303 | + | </div> | |
| 304 | + | ); | |
| 305 | + | } | |
| 306 | + | if (loaded.alreadyIn && invite.workspace) { | |
| 307 | + | return ( | |
| 308 | + | <div className="space-y-4"> | |
| 309 | + | {signedInAs} | |
| 310 | + | <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p> | |
| 311 | + | <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink> | |
| 312 | + | </div> | |
| 313 | + | ); | |
| 314 | + | } | |
| 315 | + | if (!joinable) { | |
| 316 | + | return ( | |
| 317 | + | <div className="space-y-4"> | |
| 318 | + | {signedInAs} | |
| 319 | + | <div className="rounded-md border border-line bg-surface p-4 text-sm"> | |
| 320 | + | <p> | |
| 321 | + | You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has | |
| 322 | + | nothing more to give you. | |
| 323 | + | </p> | |
| 324 | + | <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p> | |
| 325 | + | </div> | |
| 326 | + | <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div> | |
| 327 | + | </div> | |
| 328 | + | ); | |
| 329 | + | } | |
| 330 | + | return ( | |
| 331 | + | <Form method="post" className="space-y-4"> | |
| 332 | + | <input type="hidden" name="intent" value="accept" /> | |
| 333 | + | {signedInAs} | |
| 334 | + | {invite.forViewer === null && ( | |
| 335 | + | <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p> | |
| 336 | + | )} | |
| 337 | + | <ErrorText>{error}</ErrorText> | |
| 338 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 339 | + | <Button type="submit">{joinLabel(invite)}</Button> | |
| 340 | + | <span className="text-sm text-muted"> | |
| 341 | + | Not you?{" "} | |
| 342 | + | <button | |
| 343 | + | type="submit" | |
| 344 | + | formAction={`/logout?next=${encodeURIComponent(here)}`} | |
| 345 | + | className="text-fg underline underline-offset-4" | |
| 346 | + | > | |
| 347 | + | Sign out | |
| 348 | + | </button> | |
| 349 | + | </span> | |
| 350 | + | </div> | |
| 351 | + | </Form> | |
| 352 | + | ); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Someone already on g1t: sign in, and the invite is accepted on return. | |
| 356 | + | if (invite.hasAccount || invite.kind === "workspace") { | |
| 357 | + | return ( | |
| 358 | + | <div className="space-y-4"> | |
| 359 | + | <div className="rounded-md border border-line bg-surface p-4 text-sm"> | |
| 360 | + | <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p> | |
| 361 | + | <p className="mt-1 text-muted"> | |
| 362 | + | Sign in to it and {joinable ? `you ${invite.workspace ? `join ${invite.workspace.name}` : `get access to ${invite.repository!.name}`} straight away` : "the invite is accepted"}. | |
| 363 | + | </p> | |
| 364 | + | </div> | |
| 365 | + | <ErrorText>{error}</ErrorText> | |
| 366 | + | <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink> | |
| 367 | + | </div> | |
| 368 | + | ); | |
| 369 | + | } | |
| 370 | + | return <SignUp loaded={loaded} error={error} />; | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /** A code that cannot be used: why, and whom to ask. */ | |
| 374 | + | function Dead({ loaded }: { loaded: Loaded }) { | |
| 375 | + | const invite = loaded.invite; | |
| 376 | + | const title = !invite | |
| 377 | + | ? "This invite link does not work" | |
| 378 | + | : invite.status === "expired" | |
| 379 | + | ? "This invite has expired" | |
| 380 | + | : invite.status === "revoked" | |
| 381 | + | ? "This invite was withdrawn" | |
| 382 | + | : "This invite has been used"; | |
| 383 | + | const from = invite?.invitedBy; | |
| 384 | + | return ( | |
| 385 | + | <> | |
| 386 | + | <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight"> | |
| 387 | + | <CircleAlert size={22} className="shrink-0 text-warn" /> | |
| 388 | + | {title} | |
| 389 | + | </h1> | |
| 390 | + | {invite ? ( | |
| 391 | + | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 392 | + | It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"} | |
| 393 | + | {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "} | |
| 394 | + | {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."} | |
| 395 | + | </p> | |
| 396 | + | ) : ( | |
| 397 | + | <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p> | |
| 398 | + | )} | |
| 399 | + | {from && ( | |
| 400 | + | <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3"> | |
| 401 | + | <Avatar name={from.username} image={from.avatar} size={36} /> | |
| 402 | + | <p className="min-w-0 text-sm"> | |
| 403 | + | Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "} | |
| 404 | + | <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg"> | |
| 405 | + | @{from.username} | |
| 406 | + | </Link>{" "} | |
| 407 | + | <span className="text-muted">for a new invite.</span> | |
| 408 | + | </p> | |
| 409 | + | </div> | |
| 410 | + | )} | |
| 411 | + | <div className="mt-8 flex flex-wrap gap-3"> | |
| 412 | + | <ButtonLink to="/register#request">Request access</ButtonLink> | |
| 413 | + | <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet"> | |
| 414 | + | {loaded.viewer ? "Go to g1t" : "Sign in"} | |
| 415 | + | </ButtonLink> | |
| 416 | + | </div> | |
| 417 | + | </> | |
| 418 | + | ); | |
| 419 | + | } | |
| 420 | + | ||
| 70 | 421 | export default function Invite({ loaderData, actionData }: Route.ComponentProps) { | |
| 71 | − | const { invite, viewer, code, error } = loaderData; | |
| 72 | − | const here = `/invite/${code}`; | |
| 422 | + | const { invite } = loaderData; | |
| 423 | + | const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError; | |
| 424 | + | const usable = invite?.status === "pending"; | |
| 73 | 425 | return ( | |
| 74 | − | <main className="mx-auto flex max-w-md flex-col px-4 pt-20 pb-10"> | |
| 426 | + | <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20"> | |
| 75 | 427 | <Mark className="size-9" /> | |
| 76 | − | {invite ? ( | |
| 428 | + | {usable ? ( | |
| 77 | 429 | <> | |
| 78 | 430 | <div className="mt-8"> | |
| 79 | − | <Sender invite={invite} /> | |
| 431 | + | <Faces invite={invite} /> | |
| 80 | 432 | </div> | |
| 81 | − | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance">{headline(invite)}</h1> | |
| 82 | − | <p className="mt-2 text-sm leading-6 text-muted"> | |
| 83 | − | {invite.workspace | |
| 84 | − | ? `g1t is where people and agents ship software together. Accepting ${ | |
| 85 | − | invite.kind === "account" ? "makes your account and " : "" | |
| 86 | − | }joins you to the ${invite.workspace.slug} workspace.` | |
| 87 | − | : "g1t is where people and agents ship software together: plan in issues, assign work to agents like teammates, and land it through checks that hold. It is invite-only for now; this invite gets you in."} | |
| 88 | − | </p> | |
| 433 | + | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft"> | |
| 434 | + | <Headline invite={invite} /> | |
| 435 | + | </h1> | |
| 436 | + | <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p> | |
| 89 | 437 | <dl className="mt-5 space-y-1 text-sm"> | |
| 90 | 438 | {invite.invitedBy && ( | |
| 91 | 439 | <div className="flex gap-2"> | |
| 92 | − | <dt className="w-20 shrink-0 text-faint">From</dt> | |
| 440 | + | <dt className="w-24 shrink-0 text-faint">From</dt> | |
| 93 | 441 | <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd> | |
| 94 | 442 | </div> | |
| 95 | 443 | )} | |
| 96 | 444 | {invite.email && ( | |
| 97 | 445 | <div className="flex gap-2"> | |
| 98 | − | <dt className="w-20 shrink-0 text-faint">For</dt> | |
| 99 | − | <dd className="font-mono text-fg-soft">{invite.email}</dd> | |
| 446 | + | <dt className="w-24 shrink-0 text-faint">For</dt> | |
| 447 | + | <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd> | |
| 100 | 448 | </div> | |
| 101 | 449 | )} | |
| 102 | 450 | <div className="flex gap-2"> | |
| 103 | − | <dt className="w-20 shrink-0 text-faint">Works until</dt> | |
| 451 | + | <dt className="w-24 shrink-0 text-faint">Works until</dt> | |
| 104 | 452 | <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd> | |
| 105 | 453 | </div> | |
| 106 | 454 | </dl> | |
| 107 | − | ||
| 108 | 455 | <div className="mt-8"> | |
| 109 | − | {viewer ? ( | |
| 110 | − | invite.workspace ? ( | |
| 111 | − | <Form method="post" className="space-y-3"> | |
| 112 | − | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 113 | − | <Avatar name={viewer.username} image={viewer.avatar} size={20} /> | |
| 114 | − | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 115 | − | </p> | |
| 116 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 117 | − | <div className="*:w-full"> | |
| 118 | − | <Button type="submit">Join {invite.workspace.slug}</Button> | |
| 119 | − | </div> | |
| 120 | − | </Form> | |
| 121 | − | ) : ( | |
| 122 | − | <div className="rounded-md border border-line bg-surface p-4 text-sm"> | |
| 123 | − | <p> | |
| 124 | − | You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this | |
| 125 | − | invite has nothing more to give you. | |
| 126 | − | </p> | |
| 127 | − | <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p> | |
| 128 | − | </div> | |
| 129 | − | ) | |
| 130 | − | ) : invite.kind === "workspace" ? ( | |
| 131 | − | <div className="space-y-3"> | |
| 132 | − | <ButtonLink to={`/login?next=${encodeURIComponent(here)}`}>Sign in to join</ButtonLink> | |
| 133 | − | <p className="text-sm text-muted">This invite is for an address that already has a g1t account.</p> | |
| 134 | − | </div> | |
| 135 | − | ) : ( | |
| 136 | − | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 137 | − | <ButtonLink to={`/register?invite=${encodeURIComponent(code)}`}>Accept invite</ButtonLink> | |
| 138 | − | <p className="text-sm text-muted"> | |
| 139 | − | Already on g1t?{" "} | |
| 140 | − | <Link to={`/login?next=${encodeURIComponent(here)}`} className="text-fg underline underline-offset-4"> | |
| 141 | − | Sign in | |
| 142 | − | </Link> | |
| 143 | − | </p> | |
| 144 | − | </div> | |
| 145 | − | )} | |
| 456 | + | <Next loaded={loaderData} error={error} /> | |
| 146 | 457 | </div> | |
| 147 | 458 | </> | |
| 148 | 459 | ) : ( | |
| 149 | − | <> | |
| 150 | − | <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight"> | |
| 151 | − | <CircleAlert size={22} className="text-warn" /> | |
| 152 | − | This invite cannot be used | |
| 153 | − | </h1> | |
| 154 | − | <p className="mt-3 text-sm leading-6 text-muted">{error}</p> | |
| 155 | − | <div className="mt-8 flex flex-wrap gap-3"> | |
| 156 | − | <ButtonLink to="/register">Request access</ButtonLink> | |
| 157 | − | <ButtonLink to={viewer ? "/" : "/login"} variant="quiet"> | |
| 158 | − | {viewer ? "Go to g1t" : "Sign in"} | |
| 159 | − | </ButtonLink> | |
| 160 | − | </div> | |
| 161 | − | </> | |
| 460 | + | <Dead loaded={loaderData} /> | |
| 162 | 461 | )} | |
| 163 | 462 | </main> | |
| 164 | 463 | ); |
| 1 | 1 | import { ArrowUpRight } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | − | import { DEPLOYMENTS_ALLOWANCE, type FeaturePlan, type FreeTier, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts"; | |
| 3 | + | import { type FeaturePlan, type FreeTier, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/pricing"; | |
| 6 | 6 | import { wholeDollars } from "../lib/billing"; | |
| 51 | 51 | freePrivateStorageBytes: 1_000_000_000, | |
| 52 | 52 | auditRetentionDays: 90, | |
| 53 | 53 | minChargeMicros: 5_000_000, | |
| 54 | − | gitOperationsIncluded: 10_000, | |
| 55 | − | gitOperationsFreeCap: 50_000, | |
| 56 | − | planPrivateStorageBytes: 10_000_000_000, | |
| 54 | + | gitOperationsIncluded: 50_000, | |
| 57 | 55 | paidStartCeilingMicros: 100_000_000, | |
| 58 | 56 | overageForgiveCostMicros: 50_000_000, | |
| 59 | 57 | }; | |
| 60 | − | ||
| 61 | − | const A = DEPLOYMENTS_ALLOWANCE; | |
| 62 | − | const DEPLOYMENTS_LINE = `${A.apps} apps, ${A.requests / 1e6} million requests, ${A.cpuMs / 1e6} million CPU milliseconds, ${A.customDomains} custom domains and ${A.buildSeconds / 60} build minutes a month`; | |
| 63 | 58 | ||
| 64 | 59 | const DEFAULT_PLAN: FeaturePlan = { | |
| 65 | 60 | feature: "plan", | |
| 66 | 61 | title: "g1t", | |
| 67 | 62 | monthlyCents: 2000, | |
| 68 | 63 | includes: [ | |
| 69 | − | "$10 of usage each month at cost plus 20%, used first. Unused included usage does not roll over.", | |
| 64 | + | "$10 of usage each month at cost plus 20%, used first", | |
| 65 | + | "Everyone in the workspace at one price, never per person", | |
| 66 | + | "Unlimited projects, previews and repositories", | |
| 70 | 67 | "Agents, checks, workflows, the merge queue, deployments and semantic search", | |
| 71 | − | `Deployments: ${DEPLOYMENTS_LINE}`, | |
| 72 | − | "10 GB of private repository storage", | |
| 73 | − | "Unlimited members, at one price for the workspace: never per person", | |
| 68 | + | "Usage past $10 is charged at cost plus 20%, up to your spend limit", | |
| 74 | 69 | ], | |
| 75 | − | overage: "Usage past what is included is charged at cost plus 20%.", | |
| 70 | + | overage: "Everything is metered from the first unit at what it costs g1t plus 20%. Unused included usage does not roll over.", | |
| 76 | 71 | }; | |
| 77 | 72 | ||
| 78 | 73 | /** The ways to reach g1t about an enterprise account. */ | |
| 114 | 109 | plan: "From the plan's included usage, then at cost plus 20%", | |
| 115 | 110 | }, | |
| 116 | 111 | { | |
| 112 | + | what: "Projects and previews", | |
| 113 | + | free: "Not available: deployments run on g1t's machines", | |
| 114 | + | plan: "Unlimited, and never charged for. An app no one visits costs nothing.", | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | 117 | what: "Deployments", | |
| 118 | 118 | free: "Not available", | |
| 119 | − | plan: DEPLOYMENTS_LINE.replace(/^./, (c) => c.toUpperCase()) + ", then at cost plus 20%", | |
| 119 | + | plan: "Builds by the second, app requests, CPU time and custom domains, each from the first at cost plus 20%", | |
| 120 | 120 | }, | |
| 121 | 121 | { | |
| 122 | 122 | what: "Semantic search", | |
| 126 | 126 | { | |
| 127 | 127 | what: "Private storage", | |
| 128 | 128 | free: `${gigabytes(tier.freePrivateStorageBytes)}, never charged. Past it, pushes to private repositories stop until you make room or start the plan.`, | |
| 129 | − | plan: `${gigabytes(tier.planPrivateStorageBytes)}, then at cost plus 20%`, | |
| 129 | + | plan: `${gigabytes(tier.freePrivateStorageBytes)} free, then at cost plus 20%. Pushes never stop.`, | |
| 130 | 130 | note: "Public repositories are never charged for storage.", | |
| 131 | 131 | }, | |
| 132 | 132 | { | |
| 133 | 133 | what: "Git operations", | |
| 134 | − | free: `${count(tier.gitOperationsIncluded)} a month, and never charged. Past ${count(tier.gitOperationsFreeCap)} in a month, slowed to 60 an hour until the month turns.`, | |
| 135 | − | plan: `${count(tier.gitOperationsIncluded)} a month, then at cost plus 20%`, | |
| 134 | + | free: `${count(tier.gitOperationsIncluded)} a month, never charged. Past it, slowed to 60 an hour until the month turns.`, | |
| 135 | + | plan: `${count(tier.gitOperationsIncluded)} a month free, then at cost plus 20%. Never slowed.`, | |
| 136 | 136 | note: "Clones, fetches and pushes through g1t.", | |
| 137 | 137 | }, | |
| 138 | 138 | { | |
| 146 | 146 | { | |
| 147 | 147 | what: "Usage past what is included", | |
| 148 | 148 | free: "Not possible: a free workspace never runs up a bill", | |
| 149 | − | plan: "Opt in, up to a spend limit you set", | |
| 149 | + | plan: "Charged at cost plus 20%, up to a spend limit you set. No quotas: only your limit stops anything.", | |
| 150 | 150 | }, | |
| 151 | 151 | ]; | |
| 152 | 152 | } | |
| 202 | 202 | </p> | |
| 203 | 203 | </div> | |
| 204 | 204 | <div> | |
| 205 | − | <p className="font-medium">Included usage resets each month</p> | |
| 205 | + | <p className="font-medium">No quotas</p> | |
| 206 | 206 | <p className="mt-1 text-muted"> | |
| 207 | − | Unused included usage does not roll over: it starts again at $10 when the month turns. Past it, usage is | |
| 208 | − | charged at cost plus 20%. | |
| 207 | + | No count of projects, previews, builds, requests or git operations ever stops a workspace on the plan. Past | |
| 208 | + | the $10, usage is charged at cost plus 20%, and only your spend limit stops it. Unused included usage does | |
| 209 | + | not roll over. | |
| 209 | 210 | </p> | |
| 210 | 211 | </div> | |
| 211 | 212 | </div> | |
| 258 | 259 | </div> | |
| 259 | 260 | <p className="mt-3 text-sm text-muted"> | |
| 260 | 261 | Git operations cost g1t money too: Cloudflare charges g1t $0.15 per 1,000. {count(tier.gitOperationsIncluded)} a | |
| 261 | − | month is about twenty times what an active workspace uses; an agent run takes two to four. | |
| 262 | + | month is far more than an active workspace uses; an agent run takes two to four. | |
| 262 | 263 | </p> | |
| 263 | 264 | ||
| 264 | 265 | <h2 className="mt-14 text-xl font-semibold tracking-tight">No card, no compute</h2> | |
| 410 | 411 | <td className="hidden px-4 py-3 tabular-nums sm:table-cell">{book?.modelMarginPercent ?? 20}%</td> | |
| 411 | 412 | <td className="px-4 py-3 text-muted">Cost + {book?.modelMarginPercent ?? 20}%</td> | |
| 412 | 413 | </tr> | |
| 413 | − | {(book?.prices ?? []).map((price) => ( | |
| 414 | + | <tr> | |
| 415 | + | <td className="px-4 py-3"> | |
| 416 | + | <p className="font-medium">Projects, previews and apps</p> | |
| 417 | + | <p className="text-xs text-faint">An app costs only the requests and CPU it answers with</p> | |
| 418 | + | </td> | |
| 419 | + | <td className="px-4 py-3 text-muted">Next to nothing</td> | |
| 420 | + | <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td> | |
| 421 | + | <td className="px-4 py-3 text-muted">Not charged</td> | |
| 422 | + | </tr> | |
| 423 | + | {(book?.prices ?? []).filter((price) => price.meter !== "app_month").map((price) => ( | |
| 414 | 424 | <tr key={price.meter}> | |
| 415 | 425 | <td className="px-4 py-3"> | |
| 416 | 426 | <p className="font-medium">{price.title}</p> |
| 7 | 7 | import { page } from "../lib/meta"; | |
| 8 | 8 | import { AuthCard } from "../components/auth-card"; | |
| 9 | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | + | import { Honeypot } from "../components/honeypot"; | |
| 10 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 11 | 12 | import { Avatar, Button, ErrorText, Field, Input } from "../components/ui"; | |
| 12 | 13 | import { identity } from "../lib/services.server"; | |
| 31 | 32 | // one, there is no form to fill in. | |
| 32 | 33 | const checked = code ? await identity.checkInvite(code, clientKey(request)) : null; | |
| 33 | 34 | const invite: InvitePreview | null = checked?.ok ? checked.value : null; | |
| 35 | + | // A good invite is used on its own page, which knows whom it is from and | |
| 36 | + | // where it leads; it signs up, joins and lands in one go. | |
| 37 | + | if (invite && code) throw redirect(`/invite/${encodeURIComponent(code)}`); | |
| 34 | 38 | // Signing up with GitHub carries the invite code and `next` through it. | |
| 35 | 39 | const params = new URLSearchParams(); | |
| 36 | 40 | if (code) params.set("invite", code); | |
| 124 | 128 | </span> | |
| 125 | 129 | </p> | |
| 126 | 130 | </div> | |
| 127 | − | ); | |
| 128 | − | } | |
| 129 | − | ||
| 130 | − | /** The fields a bot fills in and a person never sees. */ | |
| 131 | − | function Honeypot({ started }: { started: number }) { | |
| 132 | − | return ( | |
| 133 | − | <> | |
| 134 | − | <input type="hidden" name="started" value={started} /> | |
| 135 | − | <div aria-hidden="true" className="absolute left-[-9999px] h-px w-px overflow-hidden"> | |
| 136 | − | <label> | |
| 137 | − | Website | |
| 138 | − | <input type="text" name="website" tabIndex={-1} autoComplete="off" /> | |
| 139 | − | </label> | |
| 140 | − | </div> | |
| 141 | − | </> | |
| 142 | 131 | ); | |
| 143 | 132 | } | |
| 144 | 133 | ||
| 247 | 236 | {requested ? ( | |
| 248 | 237 | <p className="mt-3 flex items-start gap-2 rounded-md border border-accent/40 bg-surface p-3 text-sm" role="status"> | |
| 249 | 238 | <CheckCircle2 size={16} className="mt-0.5 shrink-0 text-accent" /> | |
| 250 | − | <span>You are on the list. When a place opens, your invite comes to that address.</span> | |
| 239 | + | <span> | |
| 240 | + | You are on the list, and your invite comes to that address when a place opens. The first time an address | |
| 241 | + | asks, we email it to confirm. | |
| 242 | + | </span> | |
| 251 | 243 | </p> | |
| 252 | 244 | ) : ( | |
| 253 | 245 | <> |
| 1 | 1 | import { Box, Lock } from "lucide-react"; | |
| 2 | − | import { Link, NavLink, Outlet, useLocation, useRouteLoaderData } from "react-router"; | |
| 2 | + | import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Project } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 8 | 8 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 9 | 9 | import { Pill } from "../../components/ui"; | |
| 10 | 10 | import { ArchivedBanner } from "../../components/repo-lifecycle"; | |
| 11 | + | import { WelcomeBanner } from "../../components/welcome"; | |
| 12 | + | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| 11 | 13 | import { notFound } from "../../lib/not-found.server"; | |
| 12 | 14 | import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server"; | |
| 13 | 15 | import { accessFor, repoFor } from "../../lib/access.server"; | |
| 43 | 45 | // The viewer's role on the repository and what it lets them do, for the | |
| 44 | 46 | // pages under it and the sidebar. | |
| 45 | 47 | const access = accessFor(viewer, value); | |
| 46 | − | return { | |
| 48 | + | // Just given access with an invite: welcomed once (routes/invite.tsx). | |
| 49 | + | const welcome = Boolean(access.role) && welcomes(request.headers.get("cookie"), `${value.namespace}/${value.name}`); | |
| 50 | + | const headers = welcome ? { "Set-Cookie": clearWelcome(new URL(request.url).protocol === "https:") } : undefined; | |
| 51 | + | return data({ | |
| 52 | + | welcome, | |
| 47 | 53 | repo: value, | |
| 48 | 54 | project, | |
| 49 | 55 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 50 | 56 | access, | |
| 51 | 57 | member: access.insider, | |
| 52 | − | }; | |
| 58 | + | }, { headers }); | |
| 53 | 59 | } | |
| 54 | 60 | ||
| 55 | 61 | /** The project the page is in, for the pages under it. */ | |
| 149 | 155 | } | |
| 150 | 156 | ||
| 151 | 157 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 152 | − | const { repo, project, member, access } = loaderData; | |
| 158 | + | const { repo, project, member, access, welcome } = loaderData; | |
| 153 | 159 | const base = `/${repo.namespace}/${repo.name}`; | |
| 154 | 160 | const description = project?.description ?? repo.description; | |
| 155 | 161 | const { pathname } = useLocation(); | |
| 177 | 183 | </div> | |
| 178 | 184 | </div> | |
| 179 | 185 | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6"> | |
| 186 | + | {welcome && ( | |
| 187 | + | <div className="mb-6"> | |
| 188 | + | <WelcomeBanner title={`You're in ${repo.namespace}/${repo.name}`}> | |
| 189 | + | You have the {access.role} role on it now. Its code, issues and pull requests are yours to work on. | |
| 190 | + | </WelcomeBanner> | |
| 191 | + | </div> | |
| 192 | + | )} | |
| 180 | 193 | {repo.archivedAt && ( | |
| 181 | 194 | <div className="mb-6"> | |
| 182 | 195 | <ArchivedBanner base={base} owner={access.can.administer} settings={/^settings(\/|$)/.test(pathname.slice(base.length + 1))} /> |
| 71 | 71 | removing: { label: "Removing", tone: "text-faint border-line" }, | |
| 72 | 72 | }; | |
| 73 | 73 | ||
| 74 | + | /** A custom domain's monthly price, to the cent: `$0.12`. */ | |
| 75 | + | function domainPrice(micros: number | undefined): string { | |
| 76 | + | return `$${((micros ?? 120_000) / 1_000_000).toFixed(2)}`; | |
| 77 | + | } | |
| 78 | + | ||
| 74 | 79 | function StatusBadge({ status }: { status: DomainStatus }) { | |
| 75 | 80 | const { label, tone, live } = STATUS[status]; | |
| 76 | 81 | return ( | |
| 90 | 95 | } | |
| 91 | 96 | ||
| 92 | 97 | export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 93 | − | const { settings, domains, target, available, notice, included, used } = loaderData; | |
| 98 | + | const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData; | |
| 94 | 99 | const busy = useNavigation().state === "submitting"; | |
| 95 | 100 | const base = `/${params.owner}/${params.repo}`; | |
| 96 | 101 | const [hostname, setHostname] = useState(""); | |
| 186 | 191 | description="Most sites answer at both example.com and www.example.com; one serves the app and the other sends visitors to it, path and query kept." | |
| 187 | 192 | /> | |
| 188 | 193 | <p className="mt-4 text-xs text-faint"> | |
| 189 | − | The g1t plan includes {included} custom domains across the workspace ({used} in use). Each one past that is | |
| 190 | − | charged by the month at cost plus the margin. | |
| 194 | + | Each custom domain is {domainPrice(monthlyMicros)} a month on the g1t plan, its cost plus 20%, from the included usage | |
| 195 | + | first. As many as you like: {used} in use across the workspace. | |
| 191 | 196 | </p> | |
| 192 | 197 | </Form> | |
| 193 | 198 |
| 1 | − | import { identity } from "../lib/services.server"; | |
| 2 | − | import { Form, Link } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { Route } from "./+types/settings"; | |
| 5 | − | import { page } from "../lib/meta"; | |
| 6 | − | import { AvatarField } from "../components/avatar-field"; | |
| 7 | − | import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui"; | |
| 8 | − | import { readAvatarUpload } from "../lib/avatar-upload"; | |
| 9 | − | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 10 | − | import { ProfileSection } from "../components/profile-form"; | |
| 11 | − | import { InvitesSection } from "../components/invites-section"; | |
| 12 | − | import { inviteAction, loadInvites } from "../lib/invites.server"; | |
| 13 | − | import { EmailsSection, SecurityLogSection } from "../components/emails-section"; | |
| 14 | − | import { EMAIL_INTENTS, emailAction, loadEmails } from "../lib/emails.server"; | |
| 15 | − | import { GithubMark } from "../components/github"; | |
| 16 | − | import { githubSignIn } from "../lib/github.server"; | |
| 17 | − | ||
| 18 | − | export function meta(args: Route.MetaArgs) { | |
| 19 | − | return page(args, { title: "Settings · g1t" }); | |
| 20 | − | } | |
| 21 | − | ||
| 22 | − | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 23 | − | const user = requireUser(context, request); | |
| 24 | − | const [keys, tokens, applications, profile, github] = await Promise.all([ | |
| 25 | − | identity.listSshKeys(user), | |
| 26 | − | identity.listAccessTokens(user), | |
| 27 | − | identity.listOAuthGrants(user), | |
| 28 | − | identity.profile(user.username), | |
| 29 | − | githubSignIn.account(user).catch(() => null), | |
| 30 | − | ]); | |
| 31 | − | const [invites, emails] = await Promise.all([loadInvites(user), loadEmails(user)]); | |
| 32 | − | return { user, keys, tokens, applications, profile, github, invites, emails, origin: new URL(request.url).origin }; | |
| 33 | − | } | |
| 34 | − | ||
| 35 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 36 | − | assertSameOrigin(request); | |
| 37 | − | const user = requireUser(context, request); | |
| 38 | − | const form = await request.formData(); | |
| 39 | − | const id = String(form.get("id") ?? ""); | |
| 40 | − | ||
| 41 | − | // Email addresses (lib/emails.server.ts): sensitive changes may first ask | |
| 42 | − | // for the password. | |
| 43 | − | if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) { | |
| 44 | − | return { emailAction: await emailAction(user, form, request) }; | |
| 45 | − | } | |
| 46 | − | ||
| 47 | − | switch (form.get("intent")) { | |
| 48 | − | // Invites: making and revoking them (lib/invites.server.ts). | |
| 49 | − | case "create-invite": | |
| 50 | − | case "revoke-invite": { | |
| 51 | − | const done = await inviteAction(user, form); | |
| 52 | − | return { inviteCreated: done?.inviteCreated, inviteError: done?.inviteError }; | |
| 53 | − | } | |
| 54 | − | // Identity checks every field again, and the website most of all. | |
| 55 | − | case "profile": { | |
| 56 | − | const text = (name: string) => String(form.get(name) ?? ""); | |
| 57 | − | const result = await identity.updateProfile(user, { | |
| 58 | − | name: text("name"), | |
| 59 | − | bio: text("bio"), | |
| 60 | − | location: text("location"), | |
| 61 | − | website: text("website"), | |
| 62 | − | pronouns: text("pronouns"), | |
| 63 | − | }); | |
| 64 | − | return result.ok ? { profileSaved: true } : { profileError: result.error.message }; | |
| 65 | − | } | |
| 66 | − | // The picture: identity checks the image's bytes again. | |
| 67 | − | case "avatar": { | |
| 68 | − | const upload = await readAvatarUpload(form); | |
| 69 | − | if ("error" in upload) return { avatarError: upload.error }; | |
| 70 | − | const result = await identity.setUserAvatar(user, upload.image); | |
| 71 | − | return result.ok ? null : { avatarError: result.error.message }; | |
| 72 | − | } | |
| 73 | − | case "remove-avatar": { | |
| 74 | − | const result = await identity.setUserAvatar(user, null); | |
| 75 | − | return result.ok ? null : { avatarError: result.error.message }; | |
| 76 | − | } | |
| 77 | − | case "add-key": { | |
| 78 | − | const result = await identity.addSshKey( | |
| 79 | − | user, | |
| 80 | − | String(form.get("title") ?? ""), | |
| 81 | − | String(form.get("key") ?? ""), | |
| 82 | − | ); | |
| 83 | − | return result.ok ? null : { keyError: result.error.message }; | |
| 84 | − | } | |
| 85 | − | case "delete-key": | |
| 86 | − | await identity.removeSshKey(user, id); | |
| 87 | − | return null; | |
| 88 | − | case "add-token": { | |
| 89 | − | const created = await identity.createAccessToken( | |
| 90 | − | user, | |
| 91 | − | String(form.get("label") ?? ""), | |
| 92 | − | ); | |
| 93 | − | return { newToken: created.token }; | |
| 94 | − | } | |
| 95 | − | case "delete-token": | |
| 96 | − | await identity.removeAccessToken(user, id); | |
| 97 | − | return null; | |
| 98 | − | case "unlink-github": { | |
| 99 | − | const result = await githubSignIn.unlink(user); | |
| 100 | − | return result.ok ? null : { githubError: result.error.message }; | |
| 101 | − | } | |
| 102 | − | case "sign-out-application": | |
| 103 | − | await identity.revokeOAuthGrant(user, id); | |
| 104 | − | return null; | |
| 105 | − | } | |
| 106 | − | return null; | |
| 107 | − | } | |
| 108 | − | ||
| 109 | − | function DeleteButton({ | |
| 110 | − | intent, | |
| 111 | − | id, | |
| 112 | − | label = "Delete", | |
| 113 | − | }: { | |
| 114 | − | intent: string; | |
| 115 | − | id: string; | |
| 116 | − | label?: string; | |
| 117 | − | }) { | |
| 118 | − | return ( | |
| 119 | − | <Form method="post" className="ml-auto"> | |
| 120 | − | <input type="hidden" name="intent" value={intent} /> | |
| 121 | − | <input type="hidden" name="id" value={id} /> | |
| 122 | − | <Button variant="quiet" type="submit"> | |
| 123 | − | {label} | |
| 124 | − | </Button> | |
| 125 | − | </Form> | |
| 126 | − | ); | |
| 127 | − | } | |
| 128 | − | ||
| 129 | − | export default function Settings({ | |
| 130 | − | loaderData, | |
| 131 | − | actionData, | |
| 132 | − | }: Route.ComponentProps) { | |
| 133 | − | const { user, keys, tokens, applications, profile, github, invites, emails, origin } = loaderData; | |
| 134 | − | return ( | |
| 135 | − | <main className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 136 | − | <header className="mb-8 border-b border-line pb-6"> | |
| 137 | − | <h1 className="text-2xl font-semibold tracking-tight">Account</h1> | |
| 138 | − | <p className="mt-1.5 text-sm text-muted"> | |
| 139 | − | How <span className="font-mono text-fg">{user.username}</span> signs in from git, tools and agents. | |
| 140 | − | </p> | |
| 141 | − | </header> | |
| 142 | − | <div className="max-w-2xl space-y-12"> | |
| 143 | − | <section id="picture" className="scroll-mt-20"> | |
| 144 | − | <h2 className="font-medium">Picture</h2> | |
| 145 | − | <div className="mt-4"> | |
| 146 | − | <AvatarField | |
| 147 | − | name={user.username} | |
| 148 | − | image={user.avatar} | |
| 149 | − | error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined} | |
| 150 | − | about="Shown beside your username in the sidebar and on mission control. Without one, g1t draws your first letter." | |
| 151 | − | /> | |
| 152 | − | </div> | |
| 153 | − | </section> | |
| 154 | − | <ProfileSection | |
| 155 | − | username={user.username} | |
| 156 | − | profile={profile} | |
| 157 | − | error={actionData && "profileError" in actionData ? actionData.profileError : undefined} | |
| 158 | − | saved={Boolean(actionData && "profileSaved" in actionData)} | |
| 159 | − | /> | |
| 160 | − | <EmailsSection | |
| 161 | − | data={emails.emails} | |
| 162 | − | actionData={actionData && "emailAction" in actionData ? actionData.emailAction : undefined} | |
| 163 | − | hasPassword={github?.hasPassword ?? true} | |
| 164 | − | /> | |
| 165 | − | {github?.enabled && ( | |
| 166 | − | <section id="github" className="scroll-mt-20"> | |
| 167 | − | <h2 className="font-medium">GitHub</h2> | |
| 168 | − | <p className="mt-1 text-sm text-muted"> | |
| 169 | − | Sign in with your GitHub account, and import or mirror repositories you can reach there. | |
| 170 | − | </p> | |
| 171 | − | {github.account ? ( | |
| 172 | − | <div className="mt-4 flex items-center gap-4 rounded-md border border-line px-4 py-3"> | |
| 173 | − | <GithubMark className="size-5 shrink-0" /> | |
| 174 | − | <div className="min-w-0"> | |
| 175 | − | <p className="text-sm"> | |
| 176 | − | Linked to <span className="font-mono">@{github.account.login}</span> | |
| 177 | − | </p> | |
| 178 | − | <p className="text-xs text-faint"> | |
| 179 | − | Since <TimeAgo at={github.account.linkedAt} /> | |
| 180 | − | {!github.account.authorized && " · access ended: link it again to import repositories"} | |
| 181 | − | </p> | |
| 182 | − | </div> | |
| 183 | − | <div className="ml-auto flex items-center gap-2"> | |
| 184 | − | {!github.account.authorized && ( | |
| 185 | − | <a href="/auth/github?link=1" className="text-sm text-muted hover:text-fg"> | |
| 186 | − | Link again | |
| 187 | − | </a> | |
| 188 | − | )} | |
| 189 | − | <Form method="post"> | |
| 190 | − | <input type="hidden" name="intent" value="unlink-github" /> | |
| 191 | − | <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}> | |
| 192 | − | Unlink | |
| 193 | − | </Button> | |
| 194 | − | </Form> | |
| 195 | − | </div> | |
| 196 | − | </div> | |
| 197 | − | ) : ( | |
| 198 | − | <div className="mt-4"> | |
| 199 | − | <a | |
| 200 | − | href="/auth/github?link=1" | |
| 201 | − | className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg" | |
| 202 | − | > | |
| 203 | − | <GithubMark /> Link GitHub | |
| 204 | − | </a> | |
| 205 | − | </div> | |
| 206 | − | )} | |
| 207 | − | {github.account && !github.hasPassword && ( | |
| 208 | − | <p className="mt-2 text-xs text-faint"> | |
| 209 | − | GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one. | |
| 210 | − | </p> | |
| 211 | − | )} | |
| 212 | − | <ErrorText>{actionData && "githubError" in actionData ? actionData.githubError : undefined}</ErrorText> | |
| 213 | − | </section> | |
| 214 | − | )} | |
| 215 | − | <InvitesSection | |
| 216 | − | overview={invites} | |
| 217 | − | origin={origin} | |
| 218 | − | created={actionData && "inviteCreated" in actionData ? actionData.inviteCreated : undefined} | |
| 219 | − | error={actionData && "inviteError" in actionData ? actionData.inviteError : undefined} | |
| 220 | − | /> | |
| 221 | − | <section id="ssh-keys" className="scroll-mt-20"> | |
| 222 | − | <h2 className="font-medium">SSH keys</h2> | |
| 223 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 224 | − | {keys.map((key) => ( | |
| 225 | − | <li key={key.id} className="flex items-center gap-4 px-4 py-3"> | |
| 226 | − | <div className="min-w-0"> | |
| 227 | − | <p className="text-sm">{key.title}</p> | |
| 228 | − | <p className="truncate font-mono text-xs text-muted"> | |
| 229 | − | {key.fingerprint} | |
| 230 | − | </p> | |
| 231 | − | </div> | |
| 232 | − | <DeleteButton intent="delete-key" id={key.id} /> | |
| 233 | − | </li> | |
| 234 | − | ))} | |
| 235 | − | </ul> | |
| 236 | − | <Form method="post" className="mt-4 space-y-3"> | |
| 237 | − | <input type="hidden" name="intent" value="add-key" /> | |
| 238 | − | <Field label="Title (optional)"> | |
| 239 | − | <Input name="title" maxLength={100} /> | |
| 240 | − | </Field> | |
| 241 | − | <Field label="Public key"> | |
| 242 | − | <Input name="key" placeholder="ssh-ed25519 AAAA…" required /> | |
| 243 | − | </Field> | |
| 244 | − | <ErrorText>{actionData?.keyError}</ErrorText> | |
| 245 | − | <Button type="submit">Add SSH key</Button> | |
| 246 | − | </Form> | |
| 247 | − | </section> | |
| 248 | − | ||
| 249 | − | <section id="tokens" className="scroll-mt-20"> | |
| 250 | − | <h2 className="font-medium">Access tokens</h2> | |
| 251 | − | <p className="mt-1 text-sm text-muted"> | |
| 252 | − | Use a token as the password when git asks for one over HTTPS, and to | |
| 253 | − | authenticate agents and the API. A token here acts as you. | |
| 254 | − | </p> | |
| 255 | − | {(user.workspaces ?? []).length > 0 && ( | |
| 256 | − | <p className="mt-1 text-sm text-muted"> | |
| 257 | − | For CI and integrations that work for a team, use a workspace's | |
| 258 | − | own tokens instead:{" "} | |
| 259 | − | {(user.workspaces ?? []).map((membership, i) => ( | |
| 260 | − | <span key={membership.slug}> | |
| 261 | − | {i > 0 && ", "} | |
| 262 | − | <Link | |
| 263 | − | to={`/${membership.slug}/-/tokens`} | |
| 264 | − | className="font-mono text-fg underline underline-offset-4" | |
| 265 | − | > | |
| 266 | − | {membership.slug} | |
| 267 | − | </Link> | |
| 268 | − | </span> | |
| 269 | − | ))} | |
| 270 | − | . | |
| 271 | − | </p> | |
| 272 | − | )} | |
| 273 | − | {actionData?.newToken && ( | |
| 274 | − | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 275 | − | <p className="text-sm">Copy it now. It will not be shown again.</p> | |
| 276 | − | <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent"> | |
| 277 | − | {actionData.newToken} | |
| 278 | − | </pre> | |
| 279 | − | </div> | |
| 280 | − | )} | |
| 281 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 282 | − | {tokens.map((token) => ( | |
| 283 | − | <li key={token.id} className="flex items-center gap-4 px-4 py-3"> | |
| 284 | − | <div className="min-w-0"> | |
| 285 | − | <p className="truncate text-sm">{token.name}</p> | |
| 286 | − | <p className="text-xs text-faint"> | |
| 287 | − | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 288 | − | {token.lastUsedAt ? ( | |
| 289 | − | <> | |
| 290 | − | last used <TimeAgo at={token.lastUsedAt} /> | |
| 291 | − | </> | |
| 292 | − | ) : ( | |
| 293 | − | "never used" | |
| 294 | − | )} | |
| 295 | − | </p> | |
| 296 | − | </div> | |
| 297 | − | <DeleteButton intent="delete-token" id={token.id} /> | |
| 298 | − | </li> | |
| 299 | − | ))} | |
| 300 | − | </ul> | |
| 301 | − | <Form method="post" className="mt-4 flex items-end gap-3"> | |
| 302 | − | <input type="hidden" name="intent" value="add-token" /> | |
| 303 | − | <div className="grow"> | |
| 304 | − | <Field label="Name"> | |
| 305 | − | <Input name="label" maxLength={100} placeholder="laptop" /> | |
| 306 | − | </Field> | |
| 307 | − | </div> | |
| 308 | − | <Button type="submit">Create token</Button> | |
| 309 | − | </Form> | |
| 310 | − | </section> | |
| 311 | − | ||
| 312 | − | <section id="applications" className="scroll-mt-20"> | |
| 313 | − | <h2 className="font-medium">Connected applications</h2> | |
| 314 | − | <p className="mt-1 text-sm text-muted"> | |
| 315 | − | Applications you signed in to through your browser, such as an agent | |
| 316 | − | connected to the g1t MCP server. Signing one out ends its access at | |
| 317 | − | once. | |
| 318 | − | </p> | |
| 319 | − | {applications.length === 0 ? ( | |
| 320 | − | <p className="mt-4 text-sm text-faint">None yet.</p> | |
| 321 | − | ) : ( | |
| 322 | − | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 323 | − | {applications.map((application) => ( | |
| 324 | − | <li key={application.id} className="flex items-center gap-4 px-4 py-3"> | |
| 325 | − | <div> | |
| 326 | − | <p className="text-sm">{application.clientName}</p> | |
| 327 | − | <p className="text-xs text-faint"> | |
| 328 | − | Connected <TimeAgo at={application.createdAt} /> · last used{" "} | |
| 329 | − | <TimeAgo at={application.lastUsedAt} /> | |
| 330 | − | </p> | |
| 331 | − | </div> | |
| 332 | − | <DeleteButton | |
| 333 | − | intent="sign-out-application" | |
| 334 | − | id={application.id} | |
| 335 | − | label="Sign out" | |
| 336 | − | /> | |
| 337 | − | </li> | |
| 338 | − | ))} | |
| 339 | − | </ul> | |
| 340 | − | )} | |
| 341 | − | </section> | |
| 342 | − | <SecurityLogSection log={emails.log} /> | |
| 343 | − | </div> | |
| 344 | − | </main> | |
| 345 | − | ); | |
| 346 | − | } |
| 1 | + | import { identity } from "../../lib/services.server"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/applications"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 5 | + | import { TimeAgo } from "../../components/ui"; | |
| 6 | + | import { DeleteButton } from "../../components/account-settings"; | |
| 7 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 8 | + | ||
| 9 | + | export function meta(args: Route.MetaArgs) { | |
| 10 | + | return page(args, { title: "Connected applications · Settings · g1t" }); | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 14 | + | const user = requireUser(context, request); | |
| 15 | + | return { applications: await identity.listOAuthGrants(user) }; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 19 | + | assertSameOrigin(request); | |
| 20 | + | const user = requireUser(context, request); | |
| 21 | + | const form = await request.formData(); | |
| 22 | + | if (form.get("intent") === "sign-out-application") { | |
| 23 | + | await identity.revokeOAuthGrant(user, String(form.get("id") ?? "")); | |
| 24 | + | } | |
| 25 | + | return null; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export default function ApplicationSettings({ loaderData }: Route.ComponentProps) { | |
| 29 | + | const { applications } = loaderData; | |
| 30 | + | return ( | |
| 31 | + | <section id="applications" className="scroll-mt-20"> | |
| 32 | + | {applications.length === 0 ? ( | |
| 33 | + | <p className="text-sm text-faint">None yet.</p> | |
| 34 | + | ) : ( | |
| 35 | + | <ul className="divide-y divide-line rounded-md border border-line"> | |
| 36 | + | {applications.map((application) => ( | |
| 37 | + | <li key={application.id} className="flex items-center gap-4 px-4 py-3"> | |
| 38 | + | <div className="min-w-0"> | |
| 39 | + | <p className="truncate text-sm">{application.clientName}</p> | |
| 40 | + | <p className="text-xs text-faint"> | |
| 41 | + | Connected <TimeAgo at={application.createdAt} /> · last used <TimeAgo at={application.lastUsedAt} /> | |
| 42 | + | </p> | |
| 43 | + | </div> | |
| 44 | + | <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" /> | |
| 45 | + | </li> | |
| 46 | + | ))} | |
| 47 | + | </ul> | |
| 48 | + | )} | |
| 49 | + | </section> | |
| 50 | + | ); | |
| 51 | + | } |
| 1 | + | import type { Route } from "./+types/emails"; | |
| 2 | + | import { page } from "../../lib/meta"; | |
| 3 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 4 | + | import { EmailsSection } from "../../components/emails-section"; | |
| 5 | + | import { EMAIL_INTENTS, emailAction, loadEmails } from "../../lib/emails.server"; | |
| 6 | + | import { githubSignIn } from "../../lib/github.server"; | |
| 7 | + | ||
| 8 | + | export function meta(args: Route.MetaArgs) { | |
| 9 | + | return page(args, { title: "Emails · Settings · g1t" }); | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | + | const user = requireUser(context, request); | |
| 14 | + | const [emails, github] = await Promise.all([loadEmails(user), githubSignIn.account(user).catch(() => null)]); | |
| 15 | + | // Whether a sensitive change can be confirmed with a password. | |
| 16 | + | return { emails: emails.emails, hasPassword: github?.hasPassword ?? true }; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 20 | + | assertSameOrigin(request); | |
| 21 | + | const user = requireUser(context, request); | |
| 22 | + | const form = await request.formData(); | |
| 23 | + | // Sensitive changes may first ask for the password (lib/emails.server.ts). | |
| 24 | + | if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) { | |
| 25 | + | return { emailAction: await emailAction(user, form, request) }; | |
| 26 | + | } | |
| 27 | + | return null; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | export default function EmailSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 31 | + | return ( | |
| 32 | + | <EmailsSection | |
| 33 | + | data={loaderData.emails} | |
| 34 | + | actionData={actionData?.emailAction} | |
| 35 | + | hasPassword={loaderData.hasPassword} | |
| 36 | + | /> | |
| 37 | + | ); | |
| 38 | + | } |
| 1 | + | import { Form } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/github"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 5 | + | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 7 | + | import { GithubMark } from "../../components/github"; | |
| 8 | + | import { githubSignIn } from "../../lib/github.server"; | |
| 9 | + | ||
| 10 | + | export function meta(args: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: "GitHub · Settings · g1t" }); | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 15 | + | const user = requireUser(context, request); | |
| 16 | + | return { github: await githubSignIn.account(user).catch(() => null) }; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 20 | + | assertSameOrigin(request); | |
| 21 | + | const user = requireUser(context, request); | |
| 22 | + | const form = await request.formData(); | |
| 23 | + | if (form.get("intent") === "unlink-github") { | |
| 24 | + | const result = await githubSignIn.unlink(user); | |
| 25 | + | return result.ok ? null : { githubError: result.error.message }; | |
| 26 | + | } | |
| 27 | + | return null; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | export default function GithubSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 31 | + | const { github } = loaderData; | |
| 32 | + | if (!github?.enabled) { | |
| 33 | + | return <p className="text-sm text-muted">Signing in with GitHub is not set up here.</p>; | |
| 34 | + | } | |
| 35 | + | return ( | |
| 36 | + | <section id="github" className="scroll-mt-20"> | |
| 37 | + | {github.account ? ( | |
| 38 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-line px-4 py-3"> | |
| 39 | + | <GithubMark className="size-5 shrink-0" /> | |
| 40 | + | <div className="min-w-0"> | |
| 41 | + | <p className="text-sm"> | |
| 42 | + | Linked to <span className="font-mono">@{github.account.login}</span> | |
| 43 | + | </p> | |
| 44 | + | <p className="text-xs text-faint"> | |
| 45 | + | Since <TimeAgo at={github.account.linkedAt} /> | |
| 46 | + | {!github.account.authorized && " · access ended: link it again to import repositories"} | |
| 47 | + | </p> | |
| 48 | + | </div> | |
| 49 | + | <div className="ml-auto flex items-center gap-2"> | |
| 50 | + | {!github.account.authorized && ( | |
| 51 | + | <a href="/auth/github?link=1" className="text-sm text-muted hover:text-fg"> | |
| 52 | + | Link again | |
| 53 | + | </a> | |
| 54 | + | )} | |
| 55 | + | <Form method="post"> | |
| 56 | + | <input type="hidden" name="intent" value="unlink-github" /> | |
| 57 | + | <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}> | |
| 58 | + | Unlink | |
| 59 | + | </Button> | |
| 60 | + | </Form> | |
| 61 | + | </div> | |
| 62 | + | </div> | |
| 63 | + | ) : ( | |
| 64 | + | <a | |
| 65 | + | href="/auth/github?link=1" | |
| 66 | + | className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg" | |
| 67 | + | > | |
| 68 | + | <GithubMark /> Link GitHub | |
| 69 | + | </a> | |
| 70 | + | )} | |
| 71 | + | {github.account && !github.hasPassword && ( | |
| 72 | + | <p className="mt-2 text-xs text-faint"> | |
| 73 | + | GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one. | |
| 74 | + | </p> | |
| 75 | + | )} | |
| 76 | + | <ErrorText>{actionData?.githubError}</ErrorText> | |
| 77 | + | </section> | |
| 78 | + | ); | |
| 79 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.