Feat v0.5.0 realtime dialog (#30)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -255,6 +255,11 @@ export function createDomains(restClient) {
|
||||
consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2
|
||||
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
||||
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
||||
// v0.5.0: Extension permissions
|
||||
permissions: (id) => rc.get(`/api/v1/admin/extensions/${id}/permissions`),
|
||||
grantPerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/grant`, {}),
|
||||
revokePerm: (id, perm) => rc.post(`/api/v1/admin/extensions/${id}/permissions/${perm}/revoke`, {}),
|
||||
grantAllPerms: (id) => rc.post(`/api/v1/admin/extensions/${id}/permissions/grant-all`, {}),
|
||||
},
|
||||
|
||||
// v0.38.2: Full dependency graph
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createTheme } from './theme.js';
|
||||
import { createStorage } from './storage.js';
|
||||
import { createSlots } from './slots.js';
|
||||
import { createActions } from './actions.js';
|
||||
import { createRealtime } from './realtime.js';
|
||||
import { confirm } from '../primitives/confirm.js';
|
||||
import { prompt } from '../primitives/prompt.js';
|
||||
|
||||
@@ -70,6 +71,7 @@ export async function boot() {
|
||||
const storage = createStorage();
|
||||
const slots = createSlots(events.emit.bind(events));
|
||||
const actions = createActions(events.emit.bind(events));
|
||||
const realtime = createRealtime(events);
|
||||
|
||||
// 7. Assemble sw object
|
||||
const sw = Object.create(null);
|
||||
@@ -102,6 +104,9 @@ export async function boot() {
|
||||
sw.slots = slots;
|
||||
sw.actions = actions;
|
||||
|
||||
// Realtime — room-scoped pub/sub over WebSocket (v0.5.0)
|
||||
sw.realtime = realtime;
|
||||
|
||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||
sw.confirm = confirm;
|
||||
sw.prompt = prompt;
|
||||
@@ -162,7 +167,7 @@ export async function boot() {
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.2.3';
|
||||
sw._sdk = '0.5.0';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
|
||||
132
src/js/sw/sdk/realtime.js
Normal file
132
src/js/sw/sdk/realtime.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ==========================================
|
||||
// Switchboard Core — SDK: Realtime
|
||||
// ==========================================
|
||||
// Room-scoped publish/subscribe over the WebSocket bridge.
|
||||
//
|
||||
// Extensions publish events from Starlark via realtime.publish().
|
||||
// Clients subscribe to channels here — the SDK manages room
|
||||
// join/leave over WS and dispatches incoming realtime.* events.
|
||||
//
|
||||
// Factory: createRealtime(events)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Create the realtime module.
|
||||
*
|
||||
* @param {object} events — SDK event bus (from createEvents)
|
||||
* @returns {object} realtime
|
||||
*/
|
||||
export function createRealtime(events) {
|
||||
|
||||
// channel → Set<{event, fn}>
|
||||
const _channels = new Map();
|
||||
|
||||
// ── Room management ─────────────────────────
|
||||
|
||||
function _joinRoom(channel) {
|
||||
events.emit('room.subscribe', { room: channel });
|
||||
}
|
||||
|
||||
function _leaveRoom(channel) {
|
||||
events.emit('room.unsubscribe', { room: channel });
|
||||
}
|
||||
|
||||
// ── Incoming realtime events ────────────────
|
||||
|
||||
// Single wildcard listener for all realtime.* events from server
|
||||
events.on('realtime.*', (payload, meta) => {
|
||||
if (!meta?.room) return;
|
||||
|
||||
const subs = _channels.get(meta.room);
|
||||
if (!subs || subs.size === 0) return;
|
||||
|
||||
// Extract event name: "realtime.foo.bar" → "foo.bar"
|
||||
const eventName = meta.event?.replace(/^realtime\./, '') || '';
|
||||
|
||||
for (const entry of subs) {
|
||||
// Match: no event filter (catch-all) or exact event match
|
||||
if (!entry.event || entry.event === eventName) {
|
||||
try {
|
||||
entry.fn(payload, { ...meta, channel: meta.room, event: eventName });
|
||||
} catch (e) {
|
||||
console.error(`[sw.realtime] Handler error on ${meta.room}/${eventName}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Reconnect: re-join all active rooms ─────
|
||||
|
||||
events.on('ws.connected', () => {
|
||||
for (const channel of _channels.keys()) {
|
||||
if (_channels.get(channel).size > 0) {
|
||||
_joinRoom(channel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Public API ──────────────────────────────
|
||||
|
||||
const realtime = {
|
||||
/**
|
||||
* Subscribe to a realtime channel.
|
||||
*
|
||||
* @param {string} channel — room/channel name
|
||||
* @param {string|Function} eventOrCallback — event name filter or callback
|
||||
* @param {Function} [callback] — callback if event name provided
|
||||
* @returns {Function} unsubscribe
|
||||
*/
|
||||
subscribe(channel, eventOrCallback, callback) {
|
||||
if (!channel) throw new Error('sw.realtime.subscribe: channel is required');
|
||||
|
||||
let event = null;
|
||||
let fn;
|
||||
|
||||
if (typeof eventOrCallback === 'function') {
|
||||
fn = eventOrCallback;
|
||||
} else if (typeof eventOrCallback === 'string' && typeof callback === 'function') {
|
||||
event = eventOrCallback;
|
||||
fn = callback;
|
||||
} else {
|
||||
throw new Error('sw.realtime.subscribe: invalid arguments');
|
||||
}
|
||||
|
||||
const entry = { event, fn };
|
||||
|
||||
// First subscriber for this channel — join room
|
||||
if (!_channels.has(channel)) {
|
||||
_channels.set(channel, new Set());
|
||||
_joinRoom(channel);
|
||||
}
|
||||
|
||||
_channels.get(channel).add(entry);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const subs = _channels.get(channel);
|
||||
if (!subs) return;
|
||||
subs.delete(entry);
|
||||
|
||||
// Last subscriber — leave room
|
||||
if (subs.size === 0) {
|
||||
_channels.delete(channel);
|
||||
_leaveRoom(channel);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Debug helper: active channels and subscriber counts.
|
||||
* @returns {object} { channelName: count, ... }
|
||||
*/
|
||||
channels() {
|
||||
const result = {};
|
||||
for (const [channel, subs] of _channels) {
|
||||
result[channel] = subs.size;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
return realtime;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ function sourceBadge(source) {
|
||||
|
||||
function statusBadge(status) {
|
||||
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
|
||||
if (status === 'pending_review') return html`<span class="badge" style="background:var(--warning,#e6a817);color:#000;">review</span>`;
|
||||
if (status === 'suspended') return html`<span class="badge" style="background:var(--danger,#d33);color:#fff;">suspended</span>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,8 @@ export default function PackagesSection() {
|
||||
const [registryPkgs, setRegistryPkgs] = useState([]);
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [permsId, setPermsId] = useState(null); // v0.5.0: permissions drawer
|
||||
const [perms, setPerms] = useState([]);
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
|
||||
@@ -136,6 +140,46 @@ export default function PackagesSection() {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Permissions drawer (v0.5.0) ────────────
|
||||
async function togglePerms(pkgId) {
|
||||
if (permsId === pkgId) { setPermsId(null); setPerms([]); return; }
|
||||
try {
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
setPermsId(pkgId);
|
||||
// Close settings if open
|
||||
if (expandedId === pkgId) { setExpandedId(null); setPkgSettings(null); }
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function grantPerm(pkgId, perm) {
|
||||
try {
|
||||
await sw.api.admin.packages.grantPerm(pkgId, perm);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function revokePerm(pkgId, perm) {
|
||||
try {
|
||||
await sw.api.admin.packages.revokePerm(pkgId, perm);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function grantAllPerms(pkgId) {
|
||||
try {
|
||||
await sw.api.admin.packages.grantAllPerms(pkgId);
|
||||
const data = await sw.api.admin.packages.permissions(pkgId);
|
||||
setPerms((data && data.data) || data || []);
|
||||
sw.toast('All permissions granted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Registry ────────────────────────────────
|
||||
async function toggleRegistry() {
|
||||
if (registryOpen) { setRegistryOpen(false); return; }
|
||||
@@ -166,6 +210,8 @@ export default function PackagesSection() {
|
||||
const dormant = packages.filter(p => p.status === 'dormant').length;
|
||||
const hasManifestSettings = (pkg) =>
|
||||
pkg.manifest?.settings || pkg.package_settings;
|
||||
const hasDeclaredPerms = (pkg) =>
|
||||
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
@@ -211,8 +257,8 @@ export default function PackagesSection() {
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small"
|
||||
onClick=${() => toggleEnabled(pkg)}
|
||||
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant'}
|
||||
title=${pkg.status === 'dormant' ? 'Requires chat' : ''}>
|
||||
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
|
||||
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
|
||||
${pkg.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
${hasManifestSettings(pkg) && html`
|
||||
@@ -220,6 +266,11 @@ export default function PackagesSection() {
|
||||
${expandedId === pkg.id ? 'Close' : 'Settings'}
|
||||
</button>
|
||||
`}
|
||||
${hasDeclaredPerms(pkg) && html`
|
||||
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}>
|
||||
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
||||
</button>
|
||||
`}
|
||||
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
||||
${pkg.source !== 'core' && !pkg.is_system && html`
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
||||
@@ -247,6 +298,35 @@ export default function PackagesSection() {
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${/* ── Inline permissions drawer (v0.5.0) ── */``}
|
||||
${permsId === pkg.id && html`
|
||||
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
|
||||
${perms.length === 0
|
||||
? html`<span class="text-muted" style="font-size:12px;">No permissions declared</span>`
|
||||
: html`
|
||||
<div style="margin-bottom:8px;">
|
||||
${perms.map(p => html`
|
||||
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<code style="min-width:140px;font-size:12px;">${p.permission}</code>
|
||||
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}"
|
||||
style="min-width:64px;font-size:11px;"
|
||||
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
|
||||
${p.granted ? 'Revoke' : 'Grant'}
|
||||
</button>
|
||||
${p.granted && p.granted_by && html`
|
||||
<span class="text-muted" style="font-size:11px;">by ${p.granted_by}</span>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
${perms.some(p => !p.granted) && html`
|
||||
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
|
||||
`}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user