feat: add custom banner to status page (#4)

This commit is contained in:
2026-04-12 19:38:16 +02:00
committed by GitHub
parent a3acea4d35
commit 81cf70cbe3
5 changed files with 92 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ npm workspace: Cloudflare Worker (`src/`) + Astro 6 SSR app (`status-page/`).
- **Static assets**: `status-page/dist/client/` served via `ASSETS` binding (`run_worker_first = true`) - **Static assets**: `status-page/dist/client/` served via `ASSETS` binding (`run_worker_first = true`)
- **Shared types**: `src/types.ts` imported via `@worker/types` alias in `status-page/tsconfig.json` - **Shared types**: `src/types.ts` imported via `@worker/types` alias in `status-page/tsconfig.json`
- **Auth**: `status-page/src/lib/auth.ts`, configured via `STATUS_PUBLIC`, `STATUS_USERNAME`, `STATUS_PASSWORD` env vars - **Auth**: `status-page/src/lib/auth.ts`, configured via `STATUS_PUBLIC`, `STATUS_USERNAME`, `STATUS_PASSWORD` env vars
- **Banner**: Custom banner image via `STATUS_BANNER_URL` and `STATUS_BANNER_LINK` env vars
## Commands ## Commands
@@ -58,3 +59,18 @@ Before commit: `npm run check && npm run test && npm run build:pages && npm run
- **Types**: `type` for aliases, `interface` for object shapes. Explicit return types. - **Types**: `type` for aliases, `interface` for object shapes. Explicit return types.
- **Naming**: files `kebab-case.ts`, types `PascalCase`, variables `camelCase`, DB fields `snake_case`. - **Naming**: files `kebab-case.ts`, types `PascalCase`, variables `camelCase`, DB fields `snake_case`.
- **Tests**: `*.test.ts` colocated with source. Vitest with `vi` for mocks. - **Tests**: `*.test.ts` colocated with source. Vitest with `vi` for mocks.
## Banner Configuration
Set environment variables in `wrangler.toml` or Cloudflare dashboard:
```toml
[vars]
STATUS_BANNER_URL = "https://example.com/banner.png"
STATUS_BANNER_LINK = "https://example.com" # optional
```
- Banner replaces title text when `STATUS_BANNER_URL` is set
- Title is used as `alt` text for accessibility
- Link opens in same tab when `STATUS_BANNER_LINK` is set
- Empty or unset URL falls back to title display

View File

@@ -57,6 +57,7 @@ __ ,--~' ~~----____
- Response time charts (uPlot) with downtime bands. - Response time charts (uPlot) with downtime bands.
- Basic auth or public access modes. - Basic auth or public access modes.
- Dark/light mode. - Dark/light mode.
- Custom banner image support with optional clickable link.
## Architecture ## Architecture
@@ -135,7 +136,7 @@ settings:
### Per-Monitor Overrides ### Per-Monitor Overrides
Each monitor can override the global default_* settings: Each monitor can override the global default\_\* settings:
```yaml ```yaml
- name: 'critical-api' - name: 'critical-api'
@@ -271,6 +272,17 @@ wrangler secret put STATUS_PUBLIC # Set value to "true"
- If credentials are set: basic auth required - If credentials are set: basic auth required
- Otherwise: 403 Forbidden - Otherwise: 403 Forbidden
**Custom Banner:**
You can add a custom banner image to replace the title text on the status page:
```toml
# In wrangler.toml
[vars]
STATUS_BANNER_URL = "https://example.com/banner.png"
STATUS_BANNER_LINK = "https://example.com" # optional
```
## Secret Management ## Secret Management
Secrets are managed via Cloudflare's secret system. To add a new secret: Secrets are managed via Cloudflare's secret system. To add a new secret:

View File

@@ -89,6 +89,8 @@ export type Env = {
STATUS_PUBLIC?: string; STATUS_PUBLIC?: string;
REGIONAL_CHECKER_DO?: DurableObjectNamespace; REGIONAL_CHECKER_DO?: DurableObjectNamespace;
ASSETS?: Fetcher; ASSETS?: Fetcher;
STATUS_BANNER_URL?: string;
STATUS_BANNER_LINK?: string;
}; };
// Status API response types (consumed by Pages project via service binding) // Status API response types (consumed by Pages project via service binding)

View File

@@ -7,9 +7,13 @@ interface Props {
}; };
lastUpdated: number; lastUpdated: number;
title: string; title: string;
banner?: {
url?: string;
link?: string;
};
} }
const { summary, lastUpdated, title } = Astro.props; const { summary, lastUpdated, title, banner } = Astro.props;
function formatAbsoluteTime(unixTimestamp: number): string { function formatAbsoluteTime(unixTimestamp: number): string {
const date = new Date(unixTimestamp * 1000); const date = new Date(unixTimestamp * 1000);
@@ -28,7 +32,19 @@ const absoluteTime = formatAbsoluteTime(lastUpdated);
<header class="header"> <header class="header">
<div class="header-top"> <div class="header-top">
<h1>{title}</h1> {banner?.url ? (
<div class="banner-container">
{banner.link ? (
<a href={banner.link} class="banner-link" aria-label={title} target="_self">
<img src={banner.url} alt={title} class="banner-image" />
</a>
) : (
<img src={banner.url} alt={title} class="banner-image" />
)}
</div>
) : (
<h1>{title}</h1>
)}
<button class="theme-toggle" aria-label="Toggle theme" title="Toggle light/dark theme"> <button class="theme-toggle" aria-label="Toggle theme" title="Toggle light/dark theme">
<svg class="theme-icon sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="theme-icon sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/> <circle cx="12" cy="12" r="5"/>
@@ -156,6 +172,29 @@ const absoluteTime = formatAbsoluteTime(lastUpdated);
outline-offset: 2px; outline-offset: 2px;
} }
.banner-container {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
}
.banner-link {
display: block;
text-decoration: none;
transition: opacity var(--transition-normal);
}
.banner-link:hover {
opacity: 0.9;
}
.banner-image {
max-height: 60px;
max-width: 100%;
object-fit: contain;
}
.theme-icon { .theme-icon {
transition: opacity var(--transition-normal), transform var(--transition-normal); transition: opacity var(--transition-normal), transform var(--transition-normal);
} }
@@ -275,6 +314,10 @@ const absoluteTime = formatAbsoluteTime(lastUpdated);
font-size: var(--text-2xl); font-size: var(--text-2xl);
} }
.banner-image {
max-height: 50px;
}
.theme-toggle { .theme-toggle {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@@ -302,6 +345,10 @@ const absoluteTime = formatAbsoluteTime(lastUpdated);
font-size: var(--text-xl); font-size: var(--text-xl);
} }
.banner-image {
max-height: 40px;
}
.summary-counts { .summary-counts {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@@ -10,6 +10,7 @@ import { env } from 'cloudflare:workers';
let data: Awaited<ReturnType<typeof getStatusApiData>> | null = null; let data: Awaited<ReturnType<typeof getStatusApiData>> | null = null;
let error: Error | null = null; let error: Error | null = null;
let banner: { url: string; link?: string } | undefined = undefined;
try { try {
// TypeScript doesn't know about MONITORS_CONFIG in cloudflare:workers env // TypeScript doesn't know about MONITORS_CONFIG in cloudflare:workers env
@@ -21,6 +22,10 @@ try {
const configYaml = interpolateSecrets(monitorsConfig, envAny); const configYaml = interpolateSecrets(monitorsConfig, envAny);
const config = parseConfig(configYaml); const config = parseConfig(configYaml);
data = await getStatusApiData(env.DB, config); data = await getStatusApiData(env.DB, config);
const bannerUrl = envAny.STATUS_BANNER_URL;
const bannerLink = envAny.STATUS_BANNER_LINK;
banner = bannerUrl ? { url: bannerUrl, link: bannerLink } : undefined;
} catch (err) { } catch (err) {
console.error('Failed to fetch status data:', err); console.error('Failed to fetch status data:', err);
error = err as Error; error = err as Error;
@@ -55,7 +60,7 @@ const sortedMonitors = data ? [...data.monitors].sort(
</div> </div>
) : ( ) : (
<> <>
<Header summary={data!.summary} lastUpdated={data!.lastUpdated} title={data!.title} /> <Header summary={data!.summary} lastUpdated={data!.lastUpdated} title={data!.title} banner={banner} />
<div class="monitors"> <div class="monitors">
{sortedMonitors.map(monitor => <MonitorCard monitor={monitor} />)} {sortedMonitors.map(monitor => <MonitorCard monitor={monitor} />)}
</div> </div>