feat: ship Barkstack UI plugin
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m32s
Test and Release PawSQL / test (push) Successful in 50s
Test and Release PawSQL / release (push) Successful in 8s

This commit is contained in:
2026-09-16 13:47:07 -04:00
parent cae9419cd5
commit 7d7133d6cc
16 changed files with 4034 additions and 19 deletions

113
ui/src/PawSQL.svelte Normal file
View File

@@ -0,0 +1,113 @@
<svelte:options customElement={{ tag: 'barkstack-pawsql', shadow: 'open', props: { route: { type: 'String', reflect: true } } }} />
<script lang="ts">
import { onMount } from 'svelte';
import { dispatchNavigate, dispatchNotify } from './events';
type Route = { name: string; hostname?: string; managed: boolean };
type Status = { status: string; routes: Route[]; endpoint: string };
let { route = '/' }: { route?: string } = $props();
let root: HTMLElement;
let status = $state<Status | undefined>();
let loading = $state(true);
let error = $state('');
async function loadStatus() {
loading = true;
try {
const response = await fetch('/plugins/pawsql/ui/status.json', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`Status returned ${response.status}`);
status = await response.json() as Status;
error = '';
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Status is unavailable';
} finally {
loading = false;
}
}
function navigate(path: string) { dispatchNavigate(root, path); }
async function copyEndpoint() {
const endpoint = status?.endpoint ?? '*.pawsql.barkstack.dev:5432';
try {
await navigator.clipboard.writeText(endpoint);
dispatchNotify(root, 'success', 'Connection endpoint copied');
} catch {
dispatchNotify(root, 'info', endpoint);
}
}
onMount(loadStatus);
</script>
<section bind:this={root}>
<div class="intro">
<div>
<span class="eyebrow">PostgreSQL routing</span>
<h2>PawSQL</h2>
<p>Secure, service-aware PostgreSQL routing for Barkstack workloads.</p>
</div>
<div class="service-status" class:offline={!!error}>
<span></span><div><small>Status</small><strong>{error ? 'Degraded' : (status?.status ?? 'Connecting')}</strong></div>
</div>
</div>
{#if route !== '/'}
<button class="back" onclick={() => navigate('/pawsql')}> PawSQL overview</button>
{/if}
{#if loading}
<div class="loading"><span></span><span></span><span></span></div>
{:else if error}
<div class="error" role="alert"><strong>Live status is unavailable</strong><p>{error}</p><button onclick={loadStatus}>Try again</button></div>
{:else}
<div class="grid">
<article class="endpoint-card">
<span class="label">Connection endpoint</span>
<div class="endpoint"><code>{status?.endpoint}</code><button onclick={copyEndpoint} title="Copy endpoint">Copy</button></div>
<p>Use a configured database hostname or database name to select a route.</p>
</article>
<article>
<span class="label">Configured routes</span><strong class="metric">{status?.routes.length ?? 0}</strong><p>Routes loaded from this PawSQL release configuration.</p>
</article>
<article>
<span class="label">Router state</span><strong class="metric healthy">Running</strong><p>PostgreSQL listener and management UI are online.</p>
</article>
</div>
<div class="routes-panel">
<div class="panel-heading"><div><span class="eyebrow">Routing table</span><h3>{route.startsWith('/routes') ? 'All configured routes' : 'Active configuration'}</h3></div>{#if route === '/'}<button onclick={() => navigate('/pawsql/routes')}>View routes →</button>{/if}</div>
{#if !status?.routes.length}
<div class="empty">No database routes are configured.</div>
{:else}
<div class="table" role="table" aria-label="Configured PostgreSQL routes">
<div class="row header" role="row"><span>Database</span><span>Hostname</span><span>Backend</span><span>Status</span></div>
{#each status.routes as configuredRoute (configuredRoute.name)}
<div class="row" role="row"><strong>{configuredRoute.name}</strong><code>{configuredRoute.hostname || 'Database name'}</code><span>{configuredRoute.managed ? 'Managed PostgreSQL' : 'External upstream'}</span><span class="ready"><i></i>Ready</span></div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
<style>
:host { display: block; color: var(--bark-text, #edf3fb); font-family: var(--bark-font-sans, system-ui, sans-serif); }
* { box-sizing: border-box; }
section { display: grid; gap: var(--bark-space-5, 1.5rem); }
.intro { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--bark-space-5, 1.5rem); }
.eyebrow, .label { color: var(--bark-primary, #6ee7b7); font-size: .69rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
h2 { margin: .55rem 0 .35rem; font-size: 2.25rem; letter-spacing: -.05em; } h3 { margin: .4rem 0 0; font-size: 1.15rem; }
p { margin: 0; color: var(--bark-text-muted, #91a1b7); line-height: 1.6; }
.service-status { display: flex; align-items: center; gap: .65rem; padding: .65rem .9rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-md, .75rem); background: var(--bark-surface, #101827); }.service-status > span { width: .55rem; height: .55rem; border-radius: 50%; background: var(--bark-success, #4ade80); box-shadow: 0 0 0 5px color-mix(in srgb, var(--bark-success, #4ade80) 12%, transparent); }.service-status.offline > span { background: var(--bark-danger, #fb7185); }.service-status small, .service-status strong { display: block; }.service-status small { color: var(--bark-text-muted, #91a1b7); font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }.service-status strong { margin-top: .15rem; font-size: .82rem; }
.back { justify-self: start; border: 0; background: none; color: var(--bark-text-muted, #91a1b7); cursor: pointer; padding: 0; }.back:hover { color: var(--bark-text, #edf3fb); }
.grid { display: grid; grid-template-columns: 1.5fr .75fr .75fr; gap: var(--bark-space-4, 1rem); }.grid article { min-height: 10rem; padding: var(--bark-space-5, 1.5rem); border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.grid article p { margin-top: .8rem; font-size: .82rem; }.metric { display: block; margin-top: .9rem; font-size: 1.65rem; letter-spacing: -.04em; }.metric.healthy { color: var(--bark-success, #4ade80); }
.endpoint { display: flex; align-items: center; gap: .7rem; margin-top: 1rem; }.endpoint code { flex: 1; overflow: hidden; color: var(--bark-text, #edf3fb); font-family: var(--bark-font-mono, monospace); font-size: .86rem; text-overflow: ellipsis; }.endpoint button, .panel-heading button, .error button { padding: .45rem .7rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-sm, .45rem); background: var(--bark-surface-raised, #172235); color: var(--bark-text, #edf3fb); cursor: pointer; }
.routes-panel { border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); overflow: hidden; }.panel-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--bark-space-5, 1.5rem); border-bottom: 1px solid var(--bark-border, #27354a); }.panel-heading button { border: 0; background: transparent; color: var(--bark-primary, #6ee7b7); }
.row { display: grid; grid-template-columns: 1fr 1.5fr 1.25fr .6fr; gap: 1rem; align-items: center; min-height: 3.7rem; padding: .75rem var(--bark-space-5, 1.5rem); border-bottom: 1px solid var(--bark-border, #27354a); font-size: .82rem; }.row:last-child { border-bottom: 0; }.row.header { min-height: 2.8rem; color: var(--bark-text-muted, #91a1b7); background: var(--bark-surface-raised, #172235); font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }.row code { color: var(--bark-text-muted, #91a1b7); font-family: var(--bark-font-mono, monospace); }.ready { color: var(--bark-success, #4ade80); }.ready i { display: inline-block; width: .4rem; height: .4rem; margin-right: .4rem; border-radius: 50%; background: currentColor; }
.empty, .error { padding: 3rem; text-align: center; color: var(--bark-text-muted, #91a1b7); }.error { border: 1px solid color-mix(in srgb, var(--bark-danger, #fb7185) 30%, var(--bark-border, #27354a)); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.error strong { color: var(--bark-text, #edf3fb); }.error p { margin: .5rem 0 1rem; }
.loading { display: grid; gap: .8rem; padding: 2rem; border: 1px solid var(--bark-border, #27354a); border-radius: var(--bark-radius-lg, 1rem); background: var(--bark-surface, #101827); }.loading span { height: 1rem; border-radius: .3rem; background: var(--bark-surface-raised, #172235); animation: pulse 1.4s infinite; }.loading span:nth-child(2) { width: 65%; }.loading span:nth-child(3) { width: 40%; } @keyframes pulse { 50% { opacity: .4; } }
@media (max-width: 850px) { .intro { flex-direction: column; }.grid { grid-template-columns: 1fr; }.row { grid-template-columns: 1fr; gap: .35rem; padding: 1rem; }.row.header { display: none; }.endpoint { align-items: stretch; flex-direction: column; } }
</style>

1
ui/src/entry.ts Normal file
View File

@@ -0,0 +1 @@
import './PawSQL.svelte';

22
ui/src/events.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { dispatchNavigate, dispatchNotify } from './events';
describe('Barkstack host events', () => {
it('requests host-owned navigation with a composed custom event', () => {
const target = new EventTarget();
const listener = vi.fn();
target.addEventListener('barkstack:navigate', listener);
dispatchNavigate(target, '/pawsql/routes');
const event = listener.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual({ path: '/pawsql/routes' });
expect(event.composed).toBe(true);
});
it('requests a global notification', () => {
const target = new EventTarget();
let detail: unknown;
target.addEventListener('barkstack:notify', (event) => { detail = (event as CustomEvent).detail; });
dispatchNotify(target, 'success', 'Endpoint copied');
expect(detail).toEqual({ level: 'success', message: 'Endpoint copied' });
});
});

15
ui/src/events.ts Normal file
View File

@@ -0,0 +1,15 @@
export function dispatchNavigate(target: EventTarget, path: string): void {
target.dispatchEvent(new CustomEvent('barkstack:navigate', {
bubbles: true,
composed: true,
detail: { path }
}));
}
export function dispatchNotify(target: EventTarget, level: 'success' | 'info' | 'warning' | 'error', message: string): void {
target.dispatchEvent(new CustomEvent('barkstack:notify', {
bubbles: true,
composed: true,
detail: { level, message }
}));
}