Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -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; }
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,25 @@ 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 checks --watch # the Validate run, about 12 seconds
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

Expand All @@ -67,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
Expand Down Expand Up @@ -102,6 +120,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
```
62 changes: 54 additions & 8 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<svg class="logo" viewBox="0 0 343.09 116.16" role="img" aria-label="GamePad Viewer" focusable="false"><path d="${LOGO_G}"/><path d="${LOGO_P}"/><path d="${LOGO_V}"/></svg>`;

/** The same mark, as the favicon: a data URI, so it cannot 404 and costs no request. */
const FAVICON = `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><rect width="160" height="160" rx="34" fill="#0a0a0a"/><path transform="translate(20,22)" fill="#fff" d="${LOGO_G}"/></svg>`
)}`;

/**
* 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.
Expand Down Expand Up @@ -172,16 +188,22 @@ const linkList = (links) =>
const block = (heading, text) => (text ? `<p class="label">${heading}</p><p>${escape(text)}</p>` : '');

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; }
Expand All @@ -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; }
`;

Expand All @@ -218,13 +239,29 @@ const footer = () =>
({ label, href }) => `<a href="${escape(href)}" rel="noopener">${escape(label)}</a>`
).join('')}</footer>`;

const page = (title, body) => `<!doctype html>
/** 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 }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escape(title)}</title>
<meta name="robots" content="index,follow">
<meta name="description" content="${escape(summarise(description))}">
<link rel="canonical" href="${escape(url)}">
<link rel="icon" href="${escape(FAVICON)}" type="image/svg+xml">
<link rel="apple-touch-icon" href="${SITE_URL}/icon-256.png">
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<meta property="og:site_name" content="${escape(SITE_NAME)}">
<meta property="og:type" content="website">
<meta property="og:title" content="${escape(title)}">
<meta property="og:description" content="${escape(summarise(description))}">
<meta property="og:url" content="${escape(url)}">
<meta property="og:image" content="${SITE_URL}/icon-256.png">
<meta name="twitter:card" content="summary">
<style>${STYLE}</style>
</head>
<body><main>${body}${footer()}</main></body>
Expand Down Expand Up @@ -272,22 +309,31 @@ function indexPage(announcements, now) {
.join('')}</ul>`
: '';

return page(SITE_NAME, `<h1>${SITE_NAME}</h1>${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, `<h1 class="brand">${LOGO}<span class="word">status</span></h1>${current}${archive}`, {
description
});
}

function detailPage(alert) {
const ongoing = !alert.endsAt;
return page(
`${alert.title} &middot; ${SITE_NAME}`,
`<a class="back" href="/">&larr; ${SITE_NAME}</a>
`<a class="brand" href="/">${LOGO}<span class="word">status</span></a>
<span class="tag ${escape(alert.severity)}">${escape(SEVERITY_LABEL[alert.severity])}</span>
<h1>${escape(alert.title)}</h1>
<p class="when">Started ${escape(formatDate(alert.startsAt))}${
ongoing ? ' &middot; ongoing' : ` &middot; resolved ${escape(formatDate(alert.endsAt))}`
}</p>
${block('What you can do', alert.workaround)}
${alert.links.length ? '<p class="label">Links</p>' : ''}${linkList(alert.links)}
<div class="body">${marked.parse(alert.body)}</div>`
<div class="body">${marked.parse(alert.body)}</div>`,
{ description: alert.summary, url: detailUrlFor(alert) ?? SITE_URL }
);
}

Expand Down
Binary file added public/icon-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading