import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { jf, schemaFields, hasSchema, pluginHealth } from './shared/plugin-common.js'; // One plugin's admin page (`#plugin-detail?id=`), reached from the // Configure button on `#plugin-catalog` — the plugin counterpart of // `connector-detail.js`. // // Hosts what was squeezed into the old combined page: the instance-wide // config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the // per-user access checklist (`GET/PUT /api/plugins/{id}/access`). The enable // toggle is repeated in the summary card so a full setup round-trip happens // on one page. const PAGE_ID = 'plugin-detail'; function idFromHash() { const m = location.hash.match(/^#plugin-detail\?id=(.*)$/); if (!m) return null; try { return decodeURIComponent(m[1]); } catch { return null; } } export class PluginDetailPage extends LightElement { static get properties() { return { _open: { state: true }, _id: { state: true }, _plugin: { state: true }, // PluginInfo _customPage: { state: true }, // this plugin's own admin page, or null _error: { state: true }, _draft: { state: true }, // config form draft _status: { state: true }, // { ok?: string, err?: string } _access: { state: true }, // AccessEntry[] _accessSel: { state: true }, // Set of granted user ids _accessErr: { state: true }, _accessSaved: { state: true }, }; } constructor() { super(); this._open = false; this._reset(); } _reset() { this._id = null; this._plugin = null; this._customPage = null; this._error = null; this._draft = null; this._status = {}; this._access = null; this._accessSel = new Set(); this._accessErr = null; this._accessSaved = false; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadFromHash(); }); window.addEventListener('hashchange', () => { if (this._open) this._loadFromHash(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _loadFromHash() { const id = idFromHash(); if (!id) return; // A different plugin must not inherit the previous one's typed config. if (id !== this._id) this._reset(); this._id = id; await this._load(); } async _load() { this._error = null; try { const all = await jf('/api/plugins'); const p = (all ?? []).find(x => x.id === this._id) ?? null; if (!p) { this._plugin = null; this._error = t('plugins.detail.not_found', { id: this._id }); return; } this._plugin = p; // If the plugin ships its own admin page (an `admin_only` web-page), the // generic config form defers to it — see `_renderConfig`. try { const pages = await jf('/api/plugins/pages'); this._customPage = (pages ?? []).find(pg => pg.plugin_id === this._id && pg.admin_only) ?? null; } catch { this._customPage = null; } // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; // Binding-managed plugins (e.g. mobile-connector) gate access through // their own pairing lifecycle — the generic checklist controls nothing. if (!p.manages_own_access) await this._loadAccess(); } catch (e) { this._error = e.message; } } async _loadAccess() { try { const entries = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); this._access = entries; this._accessSel = new Set(entries.filter(e => e.granted).map(e => e.user_id)); } catch (e) { this._accessErr = e.message; } } _back() { // Prefer real history so the browser's own Back stays consistent; fall back // to the catalog when this page was opened straight from a pasted URL. if (history.length > 1) { history.back(); return; } history.pushState({ page: 'plugin-catalog' }, '', '#plugin-catalog'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugin-catalog' } })); } _setDraft(key, value) { this._draft = { ...this._draft, [key]: value }; } async _save(enabled) { this._status = {}; const fields = schemaFields(this._plugin.config_schema); for (const f of fields) { if (f.required && !this._draft[f.key]) { this._status = { err: t('plugins.error.required', { field: f.label }) }; return; } } try { await jf(`/api/plugins/${encodeURIComponent(this._id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, config: this._draft || {} }), }); this._status = { ok: t('plugins.saved') }; this._draft = null; // re-seed from the persisted config await this._load(); window.dispatchEvent(new CustomEvent('plugins-changed')); } catch (e) { this._status = { err: e.message }; } } _toggleAccessUser(userId, on) { const next = new Set(this._accessSel); if (on) next.add(userId); else next.delete(userId); this._accessSel = next; this._accessSaved = false; } async _saveAccess() { this._accessErr = null; this._accessSaved = false; try { await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_ids: [...this._accessSel] }), }); this._accessSaved = true; } catch (e) { this._accessErr = e.message; } } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; if (this._error && !this._plugin) { return html`
${this._renderHeader()}
${this._error}
`; } if (!this._plugin) { return html`
${this._renderHeader()}
${t('plugins.loading')}
`; } return html`
${this._renderHeader()}
${this._renderSummary()} ${this._renderConfig()} ${this._plugin.manages_own_access ? nothing : this._renderAccess()}
`; } _renderHeader() { return html`

${this._plugin?.name || this._id || 'Plugin'}

`; } _renderSummary() { const p = this._plugin; const h = pluginHealth(p); const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err'); return html`
${p.name}
${p.id}
${t(`plugins.health.${h}`)}
${p.description ? html`
${p.description}
` : nothing}
${hasSchema(p.user_config_schema) ? html` ${t('plugins.badge.user_config')}` : nothing}
this._save(e.target.checked)} />
`; } _openCustomPage(e, route) { e.preventDefault(); history.pushState({ page: route }, '', '#' + route); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: route } })); } _renderConfigLink() { const cp = this._customPage; const route = `plugin/${cp.plugin_id}/${cp.page_id}`; return html`

${t('plugins.detail.config.title')}

${t('plugins.detail.config.custom_page')}
this._openCustomPage(e, route)}> ${t('plugins.detail.config.open')}
`; } _renderConfig() { const p = this._plugin; // Defer to the plugin's own admin page when it ships one. if (this._customPage) return this._renderConfigLink(); const fields = schemaFields(p.config_schema); const draft = this._draft || {}; return html`

${t('plugins.detail.config.title')}

${fields.length === 0 ? html`
${t('plugins.detail.config.empty')}
` : html` ${fields.map(f => html`
${f.type === 'boolean' ? html`
this._setDraft(f.key, e.target.checked)} />
` : html` this._setDraft(f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`} ${f.description ? html`
${f.description}
` : nothing}
`)} ${this._status.err ? html`
${this._status.err}
` : nothing} ${this._status.ok ? html`
${this._status.ok}
` : nothing} `}
`; } _renderAccess() { return html`

${t('plugins.detail.access.title')}

${t('plugins.access.desc')}
${this._accessErr ? html`
${this._accessErr}
` : nothing} ${this._accessSaved ? html`
${t('plugins.saved')}
` : nothing} ${this._access === null ? html`
` : this._access.length === 0 ? html`

${t('plugins.access.empty')}

` : html`
${this._access.map(u => html`
this._toggleAccessUser(u.user_id, e.target.checked)} />
`)}
`}
`; } }