Add a banner to indicate updates in progress
This commit is contained in:
@@ -43,3 +43,22 @@ export function broadcastReload(reason: string, data?: Record<string, unknown>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateStatusPhase = 'request_received' | 'update_started' | 'update_done';
|
||||||
|
|
||||||
|
export function broadcastUpdateStatus(phase: UpdateStatusPhase, data?: Record<string, unknown>): void {
|
||||||
|
if (!wss) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'update_status',
|
||||||
|
ts: Date.now(),
|
||||||
|
phase,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { EditJobPayload } from '@dynamic-sites/shared';
|
import type { EditJobPayload } from '@dynamic-sites/shared';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
|
import { broadcastUpdateStatus } from '../live-reload.js';
|
||||||
|
|
||||||
export interface EditQueue {
|
export interface EditQueue {
|
||||||
enqueue(payload: EditJobPayload): void;
|
enqueue(payload: EditJobPayload): void;
|
||||||
@@ -24,11 +25,19 @@ export function createEditQueue(): EditQueue {
|
|||||||
while (jobs.length > 0 && !shuttingDown) {
|
while (jobs.length > 0 && !shuttingDown) {
|
||||||
const job = jobs.shift()!;
|
const job = jobs.shift()!;
|
||||||
logger.info({ event: 'job.started', kind: job.kind, id: job.id }, 'Processing job');
|
logger.info({ event: 'job.started', kind: job.kind, id: job.id }, 'Processing job');
|
||||||
|
broadcastUpdateStatus('update_started', { jobKind: job.kind, jobId: job.id });
|
||||||
try {
|
try {
|
||||||
await processor!(job);
|
await processor!(job);
|
||||||
logger.info({ event: 'job.completed', kind: job.kind, id: job.id }, 'Job completed');
|
logger.info({ event: 'job.completed', kind: job.kind, id: job.id }, 'Job completed');
|
||||||
|
broadcastUpdateStatus('update_done', { jobKind: job.kind, jobId: job.id, ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ event: 'job.failed', kind: job.kind, id: job.id, error: (err as Error).message }, 'Job failed');
|
logger.error({ event: 'job.failed', kind: job.kind, id: job.id, error: (err as Error).message }, 'Job failed');
|
||||||
|
broadcastUpdateStatus('update_done', {
|
||||||
|
jobKind: job.kind,
|
||||||
|
jobId: job.id,
|
||||||
|
ok: false,
|
||||||
|
error: (err as Error).message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +60,7 @@ export function createEditQueue(): EditQueue {
|
|||||||
}
|
}
|
||||||
jobs.push(payload);
|
jobs.push(payload);
|
||||||
logger.info({ event: 'job.enqueued', kind: payload.kind, id: payload.id, depth: jobs.length }, 'Job enqueued');
|
logger.info({ event: 'job.enqueued', kind: payload.kind, id: payload.id, depth: jobs.length }, 'Job enqueued');
|
||||||
|
broadcastUpdateStatus('request_received', { jobKind: payload.kind, jobId: payload.id, depth: jobs.length });
|
||||||
// Start draining on next tick
|
// Start draining on next tick
|
||||||
if (processor) setImmediate(drain);
|
if (processor) setImmediate(drain);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,9 +102,45 @@ const wsUrl = (process.env.PUBLIC_LIVE_RELOAD_WS_URL ?? import.meta.env.PUBLIC_L
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-banner {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-primary), white 92%);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.update-banner[data-visible='true'] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.update-banner__spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--color-primary), white 70%);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
animation: updateBannerSpin 0.9s linear infinite;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
@keyframes updateBannerSpin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="update-banner" class="update-banner" data-visible="false" role="status" aria-live="polite">
|
||||||
|
<span class="update-banner__spinner" aria-hidden="true"></span>
|
||||||
|
<span id="update-banner-text">Updating…</span>
|
||||||
|
</div>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="site-logo"><slot name="logo">Dynamic Site</slot></a>
|
<a href="/" class="site-logo"><slot name="logo">Dynamic Site</slot></a>
|
||||||
@@ -132,12 +168,32 @@ const wsUrl = (process.env.PUBLIC_LIVE_RELOAD_WS_URL ?? import.meta.env.PUBLIC_L
|
|||||||
|
|
||||||
const url = configuredUrl || `${proto}//${host}:${defaultPort}${path.startsWith('/') ? '' : '/'}${path}`;
|
const url = configuredUrl || `${proto}//${host}:${defaultPort}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
|
|
||||||
|
const bannerEl = document.getElementById('update-banner');
|
||||||
|
const bannerTextEl = document.getElementById('update-banner-text');
|
||||||
|
|
||||||
|
function setBanner(visible, text) {
|
||||||
|
if (!bannerEl || !bannerTextEl) return;
|
||||||
|
bannerEl.dataset.visible = visible ? 'true' : 'false';
|
||||||
|
if (typeof text === 'string' && text.length > 0) bannerTextEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(String(ev.data || ''));
|
const msg = JSON.parse(String(ev.data || ''));
|
||||||
if (msg && msg.type === 'reload') window.location.reload();
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
|
||||||
|
if (msg.type === 'reload') {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'update_status') {
|
||||||
|
if (msg.phase === 'request_received') setBanner(true, 'Processing update request');
|
||||||
|
else if (msg.phase === 'update_started') setBanner(true, 'Updating website');
|
||||||
|
else if (msg.phase === 'update_done') setBanner(false);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user