From c1ec8ab5363b84c99fa8d98449cd0438f9f31a1f Mon Sep 17 00:00:00 2001 From: Christopher Rodriguez Date: Mon, 26 Oct 2026 20:16:23 -0700 Subject: [PATCH 1/3] feat: brand the status page Uses the GPV mark from the dashboard, and the dashboard's neutral palette, so the status page reads as part of the product rather than a stray page on a subdomain. The mark is inlined as SVG paths and the favicon is a data URI, because this page has to render when the rest of the product does not: it still makes no network request of its own. The only new asset is the app icon, served from this domain rather than hotlinked, so an unfurl of an incident link does not depend on the thing that is down. Also adds per-page description, canonical, theme-color and OpenGraph metadata, so pasting an incident link into Discord shows the title and summary. Co-Authored-By: Claude Opus 5 --- build.mjs | 62 ++++++++++++++++++++++++++++++++++++++------ public/icon-256.png | Bin 0 -> 1915 bytes 2 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 public/icon-256.png diff --git a/build.mjs b/build.mjs index facd2af..e5b949e 100644 --- a/build.mjs +++ b/build.mjs @@ -14,6 +14,22 @@ const DIST = join(ROOT, 'dist'); const SITE_URL = 'https://status.gamepadviewer.com'; const SITE_NAME = 'GamePad Viewer status'; +/** + * The GPV mark, from the dashboard, inlined rather than linked: this page has to render when the + * rest of the product does not, so it makes no network request of its own. Monochrome, so it + * follows the text colour in both themes. + */ +const LOGO_G = 'M76.71,58c-6.9,0-10.36-8.35-5.48-13.23l21.87-21.87c3.37-3.37,2.92-8.95-.93-11.76C82.12,3.79,69.6-.4,56.1,.03,26.05,.99,1.44,25.23,.06,55.26c-1.53,33.28,25,60.74,57.94,60.74,11.32,0,21.88-3.25,30.81-8.87,3.04-1.91,7-1.42,9.54,1.11l5.7,5.7h0c1.37,1.37,3.31,2.18,5.43,2.03,3.73-.25,6.52-3.54,6.52-7.27v-42.96c0-4.28-3.47-7.75-7.75-7.75h-31.54Z'; +const LOGO_P = 'M186.66,0h-51.75c-4.28,0-7.75,3.47-7.75,7.75V108.25c0,4.28,3.47,7.75,7.75,7.75h25.5c4.28,0,7.75-3.47,7.75-7.75v-25.5c0-4.28,3.47-7.75,7.75-7.75h10.01c21.03,0,38.64-17.21,38.23-38.23-.39-20.37-17.03-36.77-37.49-36.77Z'; +const LOGO_V = 'M227.73,11.21l50.33,100.67c2.86,5.71,11.01,5.71,13.86,0L342.27,11.21c2.58-5.15-1.17-11.21-6.93-11.21h-100.67c-5.76,0-9.5,6.06-6.93,11.21Z'; + +const LOGO = ``; + +/** The same mark, as the favicon: a data URI, so it cannot 404 and costs no request. */ +const FAVICON = `data:image/svg+xml,${encodeURIComponent( + `` +)}`; + /** * Site-wide, and deliberately not part of any alert: this page is where people land when the * dashboard itself is unreachable, so it has to carry the ways to reach us on its own. @@ -172,16 +188,22 @@ const linkList = (links) => const block = (heading, text) => (text ? `

${heading}

${escape(text)}

` : ''); const STYLE = ` -:root { --bg:#0f1115; --card:#171a21; --line:#272b35; --text:#e7e9ee; --muted:#9aa3b2; +:root { --bg:#0a0a0a; --card:#141414; --line:#2a2a2a; --text:#fafafa; --muted:#a1a1aa; --info:#4c8dff; --warning:#e9a23b; --outage:#e5534b; color-scheme: dark; } @media (prefers-color-scheme: light) { - :root { --bg:#f7f8fa; --card:#fff; --line:#e3e6eb; --text:#1b1f27; --muted:#5d6675; color-scheme: light; } + :root { --bg:#fafafa; --card:#fff; --line:#e5e5e5; --text:#141414; --muted:#6b7280; color-scheme: light; } } * { box-sizing: border-box; } body { margin:0; padding:32px 16px 64px; background:var(--bg); color:var(--text); font:16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } main { max-width: 680px; margin: 0 auto; } h1 { font-size: 1.5rem; margin: 0 0 24px; } +.brand { display:flex; align-items:center; gap:10px; font-weight:600; letter-spacing:-.01em; } +.brand .logo { height:1.05em; width:auto; fill:currentColor; display:block; } +.brand .word { color:var(--muted); font-weight:500; } +h1.brand { margin:0 0 26px; } +a.brand { display:flex; width:fit-content; font-size:1rem; text-decoration:none; margin-bottom:22px; color:var(--muted); } +a.brand .logo { fill:var(--muted); } h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 32px 0 12px; } a { color: inherit; } @@ -208,8 +230,7 @@ a { color: inherit; } .archive time { color:var(--muted); white-space:nowrap; flex:0 0 7.5em; } footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:.9rem; } -footer a { margin-right:14px; } -.back { display:inline-block; color:var(--muted); text-decoration:none; margin-bottom:20px; } +footer a { display:inline-block; margin-right:14px; white-space:nowrap; } .body { margin-top:8px; border-top:1px solid var(--line); padding-top:20px; } `; @@ -218,13 +239,29 @@ const footer = () => ({ label, href }) => `${escape(label)}` ).join('')}`; -const page = (title, body) => ` +/** Trimmed for the places that show a page's description: search results, and a Discord unfurl. */ +const summarise = (text) => (text.length > 180 ? `${text.slice(0, 177).trimEnd()}...` : text); + +const page = (title, body, { description, url = SITE_URL }) => ` ${escape(title)} + + + + + + + + + + + + +
${body}${footer()}
@@ -272,14 +309,22 @@ function indexPage(announcements, now) { .join('')}` : ''; - return page(SITE_NAME, `

${SITE_NAME}

${current}${archive}`); + const description = active.length + ? `${active.length} active alert${active.length === 1 ? '' : 's'}: ${active + .map((alert) => alert.title) + .join(' \u00b7 ')}` + : 'All GamePad Viewer systems normal.'; + + return page(SITE_NAME, `

${LOGO}status

${current}${archive}`, { + description + }); } function detailPage(alert) { const ongoing = !alert.endsAt; return page( `${alert.title} · ${SITE_NAME}`, - `← ${SITE_NAME} + `${LOGO}status ${escape(SEVERITY_LABEL[alert.severity])}

${escape(alert.title)}

Started ${escape(formatDate(alert.startsAt))}${ @@ -287,7 +332,8 @@ function detailPage(alert) { }

${block('What you can do', alert.workaround)} ${alert.links.length ? '

Links

' : ''}${linkList(alert.links)} -
${marked.parse(alert.body)}
` +
${marked.parse(alert.body)}
`, + { description: alert.summary, url: detailUrlFor(alert) ?? SITE_URL } ); } diff --git a/public/icon-256.png b/public/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..915bbaf05fbb429505775d848e12a3b40852aa46 GIT binary patch literal 1915 zcmai#c~sJg7RP@eIys zmt06o9m55e#C=2SIU#P1Vrnjs3xk@`IsWke9=C)vR|7g@HL)KsOV(F+Y+y&7Fa5+Wtuwu z)lxg{%-qq?bBxdLn%XC7H^(E)9)v{K9>J?ac$_6WY~wM?M}HOj-nTSk;cE~+IKB|i z%Y1Qa(#-NjzsH7XIeWi14!1vU=bc}{q(LrQt?&1X(szF!Dg5q%aJ)O65fR@eF{U4b zJ~Cf5fMQN-r@cQY32QI4rDnR7jXKth2);kBav54Lk;)9N(L*G(iY|M@^__)bqKUZI zhM3Id|)nCRQF|T`79Dy`{fpUs&^#xbWp_HPD8h7_Q+5Mr5#|dw& zUJ!agt2b}-C6lU_`|52NZe{RvdlFBBzrOwnHcu!gPIKuDa6G;sniP5mX|=IHQWhWc zf{8QwFG!N2NsgnTzq(U)8jPsP@4gXS^~ejjLYvduq!=UuNZH9WqQ*5Y?q0(8<>iCP z!w9n0iq66h(d;Y&c`$Vtfok+IJ3(4oS%Ha^_*YGWSiFtRVvZ#1IPO3)4GRoO^e{fx zb1XrZfc5N42*q<6Q85B7bKX$tyJn@-D`9{Kd3_^+YW zCqU`F?e#ue^ZeH9vb^v!=zwM>(+bjD7XKXYVZ6nk=y7gRhAAz)nKwCtrgA^2;QM=p}*a0))&ppwm)XrfjtPJiZr1b0eo} zwmz{2U6OJb2ir^3u2WTp!SD)T;fJTD9SR2e4t*MeM__Twscwaj4_bZl6<(tq=VT)X@{v+SZmdco+|Lfm|bxsH0S1iT{r> zJZdHQYXy)BjH&rr>wGzA$I~dOZoGmBY^zNPy>IG9?p{Yfzr!@5IphG(mc(lj8b?Fq zU+pdzzyP!lSL?AVRYW^t=3(6%^1@-6H1tdKOFvi*N=q%jx-lrq@Wtyoiw6*x2ZxJvJJo5Su4LYaW+q=!bxRYw;ZnKLdRZ*>%x)sEg{bU{QYDy%!xl z1AnS}8L&XDL#R?Wo&N{%T_9!=CI?_1_=CxemWN~1e2d2;m`=TPg5z4;ncO~NGk$=} zmd=y+JM`MEOks~yR+lcSQiK07s{V0ERbsG%C+Pk8e~}@ZjFg>Dkky<|=uD(}-oV^y zTP3#q^TO@O^i}=oLO3eaq@!#!^r`#Lt;BUI4u^9t=CtjguVC9^`*KL%ncp1CiN7W*>q0c?rF<;p791i?ZU-WhyFq>k1*D-+vi9$xq_1S)>7F@F zQF8bFY*+b;lU@kRh58l~pAvzYg)%3?HkKG#9#(3ESkl5Ut;S(I<} zJ)s}Pr!RivSw*qto2|1H{Wp(}$hoRbhb>O;wC7s!GNp8I9)HTqj7MyopOsy`ID;iE z&VItqlKBI)y)T^foI0c@kSnU_RXUrYKSJ@J=~=`XQ;MoOhwwqeRqgc_dx)ClSS*&= zB@7F^-&3*aXZrkF?ePw8#%E}Ai;ZK;($Z3M@uAfh$!${{&Q$`b1yyN5OZulTwOh|? bCPDTA^-9)b`Jc$-{|;d6o$M;l`9Am?)@zN9 literal 0 HcmV?d00001 From a74483faf7d061d9279e124460516377732d5b0e Mon Sep 17 00:00:00 2001 From: Christopher Rodriguez Date: Mon, 26 Oct 2026 20:16:30 -0700 Subject: [PATCH 2/3] ci: validate pull requests before they can reach main Runs the same build the deploy runs, against the PR, plus a check that the feed the products read parses and carries the schema they expect. A green check means the Deploy workflow will not be the thing that discovers a broken announcement. main now requires a pull request and this check. Admins can still push directly, which the runbook documents as the deliberate escape hatch: a status page that takes a PR round trip to say "we know, don't file a bug" is the wrong trade when people are already looking at breakage. Co-Authored-By: Claude Opus 5 --- .github/workflows/pr.yml | 35 +++++++++++++++++++++++++++++++++++ README.md | 22 +++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..f692689 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,35 @@ +name: Validate + +# The same build the deploy runs, before it can reach main. A green check here means the Deploy +# workflow will not be the thing that discovers a broken announcement. +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install --no-audit --no-fund + - run: node build.mjs + - name: Check the feed the products read + run: | + node -e " + const feed = JSON.parse(require('node:fs').readFileSync('dist/alerts.json', 'utf8')); + if (feed.schema !== 'gpv-status/1') throw new Error('alerts.json has the wrong schema'); + if (!Array.isArray(feed.alerts)) throw new Error('alerts.json has no alerts array'); + console.log(\`alerts.json is valid, with \${feed.alerts.length} announcement(s)\`); + " + test -f dist/index.html || { echo 'no index.html was built'; exit 1; } diff --git a/README.md b/README.md index 195b4b9..8ba5bac 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,23 @@ Then: ```bash npm install # first time only node build.mjs # fails loudly, and lists every problem, if anything is wrong -git add -A && git commit -m "status: controller saving is down" && git push +git checkout -b status/controller-saving-down +git add -A && git commit -m "status: controller saving is down" && git push -u origin HEAD +gh pr create --fill && gh pr merge --squash --delete-branch ``` -Pushing to `main` runs the **Deploy** workflow. **If the build fails, nothing is deployed and the -previous version stays live** — which is also why the build is worth running before you push. +`main` is protected: it takes a pull request, and the **Validate** workflow — the same build, run +against the PR — has to pass before it can merge. Merging then runs the **Deploy** workflow. **If +the build fails, nothing is deployed and the previous version stays live** — which is also why the +build is worth running before you push. + +### When something is actually on fire + +Repository admins can still push straight to `main`, and that is deliberate: this site exists to +say something within seconds, and a PR round trip is the wrong thing to owe an audience that is +already looking at breakage. Use it when the delay genuinely matters — and run `node build.mjs` +first, because the failure mode of skipping it is silent. A bad file fails the deploy, the previous +page stays live, and the alert you were in a hurry to publish simply never appears. ### Resolving an alert @@ -102,6 +114,6 @@ rather than breaking a page. The canonical schema lives in the `gpv` repository, ``` announcements/ one markdown file per incident build.mjs validates frontmatter, writes dist/ -public/ copied verbatim into dist/ (CNAME, .nojekyll) -.github/workflows/ the Pages deploy +public/ copied verbatim into dist/ (CNAME, .nojekyll, the icon) +.github/workflows/ the PR check, and the Pages deploy ``` From fae28df9a70a9f8cb24f19a1790e999894bccfe8 Mon Sep 17 00:00:00 2001 From: Christopher Rodriguez Date: Mon, 26 Oct 2026 20:17:43 -0700 Subject: [PATCH 3/3] docs: record what the PR gate costs 12 seconds for the Validate run, measured on the PR that introduced it, ahead of the 30 seconds the deploy already took. Also corrects the publishing steps: the check has to finish before the merge will go through. Co-Authored-By: Claude Opus 5 --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ba5bac..31d703e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ npm install # first time only node build.mjs # fails loudly, and lists every problem, if anything is wrong git checkout -b status/controller-saving-down git add -A && git commit -m "status: controller saving is down" && git push -u origin HEAD -gh pr create --fill && gh pr merge --squash --delete-branch +gh pr create --fill +gh pr checks --watch # the Validate run, about 12 seconds +gh pr merge --squash --delete-branch ``` `main` is protected: it takes a pull request, and the **Validate** workflow — the same build, run @@ -79,12 +81,16 @@ further push from you. Useful for planned maintenance. ## How long it takes to go live -**About 30 seconds from `git push`**, and roughly 90 seconds to a tab that is already open. +**About 30 seconds from the merge**, and roughly 90 seconds to a tab that is already open. Measured on a real change: pushed at 02:53:03, the Deploy workflow ran 02:53:05 to 02:53:31, and the live file changed 32 seconds after the push — about a second after the deploy finished. Deploying is the whole cost. +A pull request adds the **Validate** run ahead of that — 12 seconds, measured on the PR that +introduced it — so an alert published the normal way is live about three quarters of a minute after +you open the PR. An admin pushing straight to `main` is back to the 30 seconds above. + GitHub Pages serves `cache-control: max-age=600` and gives no way to change it, but that does not delay a release: publishing purges the CDN, which is why the new file was served immediately. The 600 seconds only ever applied to browsers reusing their own cache, and the dashboard and overlay