feat(serviceworker): Add service worker status updates, EventBus and UI status pill for realtime observability

This commit is contained in:
2025-12-04 14:09:10 +00:00
parent 8bb4814350
commit c4e0e9b915
7 changed files with 924 additions and 17 deletions

View File

@@ -3,12 +3,12 @@ import * as interfaces from '../dist_ts_interfaces/index.js';
import { logger } from './typedserver_web.logger.js';
logger.log('info', `TypedServer-Devtools initialized!`);
import { TypedserverInfoscreen } from './typedserver_web.infoscreen.js';
import { TypedserverStatusPill } from './typedserver_web.statuspill.js';
export class ReloadChecker {
public reloadJustified = false;
public backendConnectionLost = false;
public infoscreen = new TypedserverInfoscreen();
public statusPill = new TypedserverStatusPill();
public store = new plugins.webstore.WebStore({
dbName: 'apiglobal__typedserver',
storeName: 'apiglobal__typedserver',
@@ -17,14 +17,90 @@ export class ReloadChecker {
public typedsocket: plugins.typedsocket.TypedSocket;
public typedrouter = new plugins.typedrequest.TypedRouter();
private swStatusUnsubscribe: (() => void) | null = null;
constructor() {}
constructor() {
// Listen to browser online/offline events
window.addEventListener('online', () => {
this.statusPill.updateStatus({
source: 'network',
type: 'online',
message: 'Back online',
persist: false,
timestamp: Date.now(),
});
});
window.addEventListener('offline', () => {
this.statusPill.updateStatus({
source: 'network',
type: 'offline',
message: 'No internet connection',
persist: true,
timestamp: Date.now(),
});
});
}
public async reload() {
// this looks a bit hacky, but apparently is the safest way to really reload stuff
window.location.reload();
}
/**
* Subscribe to service worker status updates
*/
public subscribeToServiceWorker(): void {
// Check if service worker client is available
if (globalThis.globalSw?.actionManager) {
this.swStatusUnsubscribe = globalThis.globalSw.actionManager.subscribeToStatusUpdates((status) => {
this.statusPill.updateStatus({
source: status.source,
type: status.type,
message: status.message,
details: status.details,
persist: status.persist || false,
timestamp: status.timestamp,
});
});
logger.log('info', 'Subscribed to service worker status updates');
// Get initial SW status
this.fetchServiceWorkerStatus();
} else {
logger.log('note', 'Service worker client not available yet, will retry...');
// Retry after a delay
setTimeout(() => this.subscribeToServiceWorker(), 2000);
}
}
/**
* Fetch and display initial service worker status
*/
private async fetchServiceWorkerStatus(): Promise<void> {
if (!globalThis.globalSw?.actionManager) return;
try {
const status = await globalThis.globalSw.actionManager.getServiceWorkerStatus();
if (status) {
this.statusPill.updateStatus({
source: 'serviceworker',
type: status.isActive ? 'connected' : 'disconnected',
message: status.isActive ? 'Service worker active' : 'Service worker inactive',
details: {
cacheHitRate: status.cacheHitRate,
resourceCount: status.resourceCount,
connectionType: status.connectionType,
},
persist: false,
timestamp: Date.now(),
});
}
} catch (error) {
logger.log('warn', `Failed to get SW status: ${error}`);
}
}
/**
* starts the reload checker
*/
@@ -50,11 +126,23 @@ export class ReloadChecker {
if (response?.status !== 200) {
this.backendConnectionLost = true;
logger.log('warn', `got a status ${response?.status}.`);
this.infoscreen.setText(`backend connection lost... Status ${response?.status}`);
this.statusPill.updateStatus({
source: 'backend',
type: 'disconnected',
message: `Backend connection lost (${response?.status || 'timeout'})`,
persist: true,
timestamp: Date.now(),
});
}
if (response?.status === 200 && this.backendConnectionLost) {
this.backendConnectionLost = false;
this.infoscreen.setSuccess('regained connection to backend...');
this.statusPill.updateStatus({
source: 'backend',
type: 'connected',
message: 'Backend connection restored',
persist: false,
timestamp: Date.now(),
});
}
return response;
}
@@ -69,10 +157,15 @@ export class ReloadChecker {
if (reloadJustified) {
this.store.set(this.storeKey, lastServerChange);
const reloadText = `upgrading... ${
globalThis.globalSw ? '(purging the sw cache first...)' : ''
}`;
this.infoscreen.setText(reloadText);
const hasSw = !!globalThis.globalSw;
this.statusPill.updateStatus({
source: 'serviceworker',
type: 'update',
message: hasSw ? 'Updating app...' : 'Upgrading...',
persist: true,
timestamp: Date.now(),
});
if (globalThis.globalSw?.purgeCache) {
await globalThis.globalSw.purgeCache();
} else if ('caches' in window) {
@@ -87,14 +180,19 @@ export class ReloadChecker {
} else {
console.log('globalThis.globalSw not found and Cache API not available...');
}
this.infoscreen.setText(`cleaned caches`);
this.statusPill.updateStatus({
source: 'serviceworker',
type: 'cache',
message: 'Cache cleared, reloading...',
persist: true,
timestamp: Date.now(),
});
await plugins.smartdelay.delayFor(200);
this.reload();
return;
} else {
if (this.infoscreen) {
this.infoscreen.hide();
}
// All good, hide after brief show
return;
}
}
@@ -116,10 +214,22 @@ export class ReloadChecker {
console.log(`typedsocket status: ${statusArg}`);
if (statusArg === 'disconnected' || statusArg === 'reconnecting') {
this.backendConnectionLost = true;
this.infoscreen.setText(`typedsocket ${statusArg}!`);
this.statusPill.updateStatus({
source: 'backend',
type: statusArg === 'disconnected' ? 'disconnected' : 'reconnecting',
message: `TypedSocket ${statusArg}`,
persist: true,
timestamp: Date.now(),
});
} else if (statusArg === 'connected' && this.backendConnectionLost) {
this.backendConnectionLost = false;
this.infoscreen.setSuccess('typedsocket connected!');
this.statusPill.updateStatus({
source: 'backend',
type: 'connected',
message: 'TypedSocket connected',
persist: false,
timestamp: Date.now(),
});
// lets check if a reload is necessary
const getLatestServerChangeTime =
this.typedsocket.createTypedRequest<interfaces.IReq_GetLatestServerChangeTime>(
@@ -137,9 +247,13 @@ export class ReloadChecker {
public async start() {
this.started = true;
logger.log('info', `starting ReloadChecker...`);
// Subscribe to service worker status updates
this.subscribeToServiceWorker();
while (this.started) {
const response = await this.performHttpRequest();
if (response.status === 200) {
if (response?.status === 200) {
logger.log('info', `ReloadChecker reached backend!`);
await this.checkReload(parseInt(await response.text()));
await this.connectTypedsocket();
@@ -150,6 +264,10 @@ export class ReloadChecker {
public async stop() {
this.started = false;
if (this.swStatusUnsubscribe) {
this.swStatusUnsubscribe();
this.swStatusUnsubscribe = null;
}
}
}

View File

@@ -0,0 +1,534 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import * as plugins from './typedserver_web.plugins.js';
declare global {
interface HTMLElementTagNameMap {
'typedserver-statuspill': TypedserverStatusPill;
}
}
/**
* Status source types
*/
export type TStatusSource = 'backend' | 'serviceworker' | 'network';
/**
* Status type
*/
export type TStatusType = 'connected' | 'disconnected' | 'reconnecting' | 'update' | 'cache' | 'error' | 'offline' | 'online';
/**
* Status item with details
*/
export interface IStatusItem {
source: TStatusSource;
type: TStatusType;
message: string;
details?: {
version?: string;
cacheHitRate?: number;
resourceCount?: number;
connectionType?: string;
latencyMs?: number;
};
persist: boolean;
timestamp: number;
}
/**
* Modern status pill component that displays connection and service worker status
* - Shows at center-bottom on connectivity changes
* - Stays visible during error states
* - Expands on hover to show detailed status
*/
@customElement('typedserver-statuspill')
export class TypedserverStatusPill extends LitElement {
// Current status items by source
@state() accessor backendStatus: IStatusItem | null = null;
@state() accessor swStatus: IStatusItem | null = null;
@state() accessor networkStatus: IStatusItem | null = null;
// UI state
@state() accessor visible = false;
@state() accessor expanded = false;
@state() accessor hasError = false;
// Hide timeout
private hideTimeout: number | null = null;
private appended = false;
public static styles = css`
* {
box-sizing: border-box;
}
:host {
--pill-bg: rgba(20, 20, 20, 0.9);
--pill-bg-error: rgba(180, 40, 40, 0.95);
--pill-bg-success: rgba(40, 140, 60, 0.95);
--pill-text: #fff;
--pill-text-muted: rgba(255, 255, 255, 0.7);
--pill-border: rgba(255, 255, 255, 0.1);
--pill-accent: #4af;
--pill-success: #4f8;
--pill-warning: #fa4;
--pill-error: #f44;
}
.pill {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--pill-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 24px;
padding: 10px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: var(--pill-text);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
pointer-events: none;
z-index: 10000;
max-width: 90vw;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
border: 1px solid var(--pill-border);
}
.pill.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
.pill.error {
background: var(--pill-bg-error);
}
.pill.success {
background: var(--pill-bg-success);
}
.pill-main {
display: flex;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pill-text-muted);
transition: background 0.3s;
}
.status-dot.connected {
background: var(--pill-success);
box-shadow: 0 0 6px var(--pill-success);
}
.status-dot.disconnected,
.status-dot.offline,
.status-dot.error {
background: var(--pill-error);
box-shadow: 0 0 6px var(--pill-error);
}
.status-dot.reconnecting,
.status-dot.update {
background: var(--pill-warning);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-message {
color: var(--pill-text);
font-weight: 400;
}
.separator {
width: 1px;
height: 16px;
background: var(--pill-border);
}
.pill-expanded {
display: none;
width: 100%;
padding-top: 8px;
border-top: 1px solid var(--pill-border);
flex-direction: column;
gap: 6px;
}
.pill.expanded .pill-expanded {
display: flex;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
gap: 20px;
}
.detail-label {
color: var(--pill-text-muted);
}
.detail-value {
color: var(--pill-text);
font-weight: 500;
}
.detail-value.success {
color: var(--pill-success);
}
.detail-value.error {
color: var(--pill-error);
}
.detail-value.warning {
color: var(--pill-warning);
}
/* Click hint */
.pill::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 3px;
background: var(--pill-border);
border-radius: 2px;
transition: background 0.2s;
}
.pill:hover::after {
background: var(--pill-text-muted);
}
`;
/**
* Update status from a specific source
*/
public updateStatus(status: IStatusItem): void {
// Store by source
switch (status.source) {
case 'backend':
this.backendStatus = status;
break;
case 'serviceworker':
this.swStatus = status;
break;
case 'network':
this.networkStatus = status;
break;
}
// Determine if we have any errors (should persist)
this.hasError = this.hasAnyError();
// Show the pill
this.show();
// Auto-hide after delay if not persistent
if (!status.persist && !this.hasError) {
this.scheduleHide(2500);
} else {
this.cancelHide();
}
}
/**
* Check if any status is an error state
*/
private hasAnyError(): boolean {
const errorTypes: TStatusType[] = ['disconnected', 'error', 'offline'];
return (
(this.backendStatus && errorTypes.includes(this.backendStatus.type)) ||
(this.networkStatus && errorTypes.includes(this.networkStatus.type)) ||
false
);
}
/**
* Get overall status class
*/
private getStatusClass(): string {
if (this.hasError) return 'error';
const latestStatus = this.getLatestStatus();
if (latestStatus?.type === 'connected' || latestStatus?.type === 'online') {
return 'success';
}
return '';
}
/**
* Get the most recent status
*/
private getLatestStatus(): IStatusItem | null {
const statuses = [this.backendStatus, this.swStatus, this.networkStatus].filter(Boolean) as IStatusItem[];
if (statuses.length === 0) return null;
return statuses.reduce((latest, current) =>
current.timestamp > latest.timestamp ? current : latest
);
}
/**
* Show the pill
*/
public show(): void {
if (!this.appended) {
document.body.appendChild(this);
this.appended = true;
}
// Small delay to ensure DOM update
requestAnimationFrame(() => {
this.visible = true;
});
}
/**
* Hide the pill
*/
public hide(): void {
this.visible = false;
this.expanded = false;
}
/**
* Schedule auto-hide
*/
private scheduleHide(delayMs: number): void {
this.cancelHide();
this.hideTimeout = window.setTimeout(() => {
if (!this.hasError) {
this.hide();
}
}, delayMs);
}
/**
* Cancel scheduled hide
*/
private cancelHide(): void {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
}
/**
* Toggle expanded state
*/
private toggleExpanded(): void {
this.expanded = !this.expanded;
if (this.expanded) {
this.cancelHide();
}
}
/**
* Clear all status and hide
*/
public clearStatus(): void {
this.backendStatus = null;
this.swStatus = null;
this.networkStatus = null;
this.hasError = false;
this.hide();
}
/**
* Set success message (auto-hides)
*/
public setSuccess(message: string, source: TStatusSource = 'backend'): void {
this.updateStatus({
source,
type: 'connected',
message,
persist: false,
timestamp: Date.now(),
});
}
/**
* Set error message (persists)
*/
public setError(message: string, source: TStatusSource = 'backend'): void {
this.updateStatus({
source,
type: 'error',
message,
persist: true,
timestamp: Date.now(),
});
}
/**
* Set transitional message (auto-hides)
*/
public setText(message: string, source: TStatusSource = 'backend'): void {
this.updateStatus({
source,
type: 'reconnecting',
message,
persist: false,
timestamp: Date.now(),
});
}
/**
* Render status indicators
*/
private renderStatusIndicators() {
const indicators = [];
if (this.networkStatus) {
indicators.push(html`
<div class="status-indicator">
<span class="status-dot ${this.networkStatus.type}"></span>
<span class="status-label">Net</span>
</div>
`);
}
if (this.backendStatus) {
indicators.push(html`
<div class="status-indicator">
<span class="status-dot ${this.backendStatus.type}"></span>
<span class="status-label">API</span>
</div>
`);
}
if (this.swStatus) {
indicators.push(html`
<div class="status-indicator">
<span class="status-dot ${this.swStatus.type}"></span>
<span class="status-label">SW</span>
</div>
`);
}
return indicators;
}
/**
* Render expanded details
*/
private renderDetails() {
const details = [];
if (this.networkStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Network</span>
<span class="detail-value ${this.networkStatus.type === 'online' ? 'success' : 'error'}">
${this.networkStatus.message}
${this.networkStatus.details?.connectionType ? ` (${this.networkStatus.details.connectionType})` : ''}
</span>
</div>
`);
}
if (this.backendStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Backend</span>
<span class="detail-value ${this.backendStatus.type === 'connected' ? 'success' : this.backendStatus.type === 'reconnecting' ? 'warning' : 'error'}">
${this.backendStatus.message}
</span>
</div>
`);
}
if (this.swStatus) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Service Worker</span>
<span class="detail-value ${this.swStatus.type === 'connected' ? 'success' : this.swStatus.type === 'update' ? 'warning' : ''}">
${this.swStatus.message}
${this.swStatus.details?.version ? ` v${this.swStatus.details.version}` : ''}
</span>
</div>
`);
if (this.swStatus.details?.cacheHitRate !== undefined) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Cache Hit Rate</span>
<span class="detail-value">${this.swStatus.details.cacheHitRate.toFixed(1)}%</span>
</div>
`);
}
if (this.swStatus.details?.resourceCount !== undefined) {
details.push(html`
<div class="detail-row">
<span class="detail-label">Cached Resources</span>
<span class="detail-value">${this.swStatus.details.resourceCount}</span>
</div>
`);
}
}
return details;
}
public render() {
const latestStatus = this.getLatestStatus();
const message = latestStatus?.message || '';
const indicators = this.renderStatusIndicators();
return html`
<div
class="pill ${this.visible ? 'visible' : ''} ${this.getStatusClass()} ${this.expanded ? 'expanded' : ''}"
@click="${this.toggleExpanded}"
>
<div class="pill-main">
${indicators.length > 0 ? html`
${indicators}
${message ? html`<span class="separator"></span>` : ''}
` : ''}
${message ? html`<span class="status-message">${message}</span>` : ''}
</div>
<div class="pill-expanded">
${this.renderDetails()}
</div>
</div>
`;
}
}