feat: ship Barkstack UI plugin
This commit is contained in:
113
ui/src/PawSQL.svelte
Normal file
113
ui/src/PawSQL.svelte
Normal 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
1
ui/src/entry.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './PawSQL.svelte';
|
||||
22
ui/src/events.test.ts
Normal file
22
ui/src/events.test.ts
Normal 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
15
ui/src/events.ts
Normal 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 }
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user