Add plugin package installation UI
This commit is contained in:
+84
-1
@@ -40,6 +40,7 @@
|
|||||||
await loadCollaborators();
|
await loadCollaborators();
|
||||||
await loadBotCommands();
|
await loadBotCommands();
|
||||||
await loadBotSecrets();
|
await loadBotSecrets();
|
||||||
|
await loadPlugins();
|
||||||
await loadOverlays();
|
await loadOverlays();
|
||||||
await loadBackups();
|
await loadBackups();
|
||||||
await loadStreamInfo();
|
await loadStreamInfo();
|
||||||
@@ -172,6 +173,10 @@
|
|||||||
let secretDescription = '';
|
let secretDescription = '';
|
||||||
let secretValue = '';
|
let secretValue = '';
|
||||||
let secretError = '';
|
let secretError = '';
|
||||||
|
let plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; api_version: string; entrypoint: string }; enabled: boolean; installed_at: string; updated_at: string }> = [];
|
||||||
|
let pluginError = '';
|
||||||
|
let pluginBusy = false;
|
||||||
|
let pluginDropActive = false;
|
||||||
let overlays: Array<{ id: string; name: string; slug: string; draft_html: string; draft_css: string; draft_javascript: string; published: boolean; enabled: boolean; disabled_design: string }> = [];
|
let overlays: Array<{ id: string; name: string; slug: string; draft_html: string; draft_css: string; draft_javascript: string; published: boolean; enabled: boolean; disabled_design: string }> = [];
|
||||||
let selectedOverlayId = '';
|
let selectedOverlayId = '';
|
||||||
let overlayName = '';
|
let overlayName = '';
|
||||||
@@ -389,6 +394,7 @@
|
|||||||
chatReplyParent = undefined;
|
chatReplyParent = undefined;
|
||||||
chatThreadRoot = undefined;
|
chatThreadRoot = undefined;
|
||||||
pinnedMessageIds = [];
|
pinnedMessageIds = [];
|
||||||
|
plugins = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectRealtime() {
|
function connectRealtime() {
|
||||||
@@ -928,6 +934,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPlugins() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||||||
|
if (!response.ok) throw new Error('Plugins could not be loaded.');
|
||||||
|
plugins = await response.json();
|
||||||
|
pluginError = '';
|
||||||
|
} catch (error) {
|
||||||
|
pluginError = error instanceof Error ? error.message : 'Plugins could not be loaded.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPluginFile(file: File) {
|
||||||
|
if (file.size > 6 * 1024 * 1024) {
|
||||||
|
pluginError = 'Plugin package is too large (maximum 6 MiB).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pluginBusy = true;
|
||||||
|
pluginError = '';
|
||||||
|
try {
|
||||||
|
const packageText = await file.text();
|
||||||
|
const packageValue = JSON.parse(packageText) as { manifest?: unknown; wasm_base64?: unknown };
|
||||||
|
if (!packageValue.manifest || typeof packageValue.wasm_base64 !== 'string') throw new Error('Package must contain manifest and wasm_base64 fields.');
|
||||||
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify(packageValue) });
|
||||||
|
const result = (await response.json().catch(() => ({}))) as { code?: string; reason?: string };
|
||||||
|
if (!response.ok) throw new Error(result.reason || result.code || 'Plugin installation failed.');
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (error) {
|
||||||
|
pluginError = error instanceof Error ? error.message : 'Plugin installation failed.';
|
||||||
|
} finally {
|
||||||
|
pluginBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginFileInput(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
|
if (file) void installPluginFile(file);
|
||||||
|
(event.currentTarget as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
pluginDropActive = false;
|
||||||
|
const file = event.dataTransfer?.files?.[0];
|
||||||
|
if (file) void installPluginFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPluginEnabled(plugin: (typeof plugins)[number]) {
|
||||||
|
pluginBusy = true;
|
||||||
|
pluginError = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ enabled: !plugin.enabled }) });
|
||||||
|
if (!response.ok) throw new Error('Plugin state could not be changed.');
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (error) {
|
||||||
|
pluginError = error instanceof Error ? error.message : 'Plugin state could not be changed.';
|
||||||
|
} finally {
|
||||||
|
pluginBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlugin(plugin: (typeof plugins)[number]) {
|
||||||
|
if (!window.confirm(`Remove plugin ${plugin.name}?`)) return;
|
||||||
|
pluginBusy = true;
|
||||||
|
pluginError = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
||||||
|
if (!response.ok) throw new Error('Plugin could not be removed.');
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (error) {
|
||||||
|
pluginError = error instanceof Error ? error.message : 'Plugin could not be removed.';
|
||||||
|
} finally {
|
||||||
|
pluginBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverlays() {
|
async function loadOverlays() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||||||
@@ -1150,7 +1231,7 @@
|
|||||||
<header class="app-bar">
|
<header class="app-bar">
|
||||||
<div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div>
|
<div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div>
|
||||||
<nav class="top-nav" aria-label="Primary navigation">
|
<nav class="top-nav" aria-label="Primary navigation">
|
||||||
{#each ['Live dashboard', 'Chat', 'Bot', 'Collaboration', 'Overlays', 'Settings'] as item}
|
{#each ['Live dashboard', 'Chat', 'Bot', 'Plugins', 'Collaboration', 'Overlays', 'Settings'] as item}
|
||||||
<button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
|
<button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -1230,6 +1311,8 @@
|
|||||||
{#if authError}<div class="auth-error">{authError}</div>{/if}
|
{#if authError}<div class="auth-error">{authError}</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{:else if activeView === 'Plugins'}
|
||||||
|
<section class="plugins-page panel"><div class="panel-header"><span>WEBASSEMBLY PLUGINS</span><button class="secondary" onclick={loadPlugins}>REFRESH</button></div><div class="settings-body"><p class="settings-note">Packages are validated and started in a resource-limited WebAssembly runtime. Only explicitly declared Trixie permissions are accepted; installation never grants filesystem or process access.</p><label class:drop-active={pluginDropActive} class="plugin-drop-zone" ondragover={(event) => { event.preventDefault(); pluginDropActive = true; }} ondragleave={() => (pluginDropActive = false)} ondrop={handlePluginDrop}><strong>DROP PLUGIN PACKAGE HERE</strong><span>or choose a `.trixie-plugin` JSON package</span><input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label><div class="overlay-controls"><label class="primary plugin-file-button">CHOOSE PACKAGE<input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label></div>{#if pluginError}<div class="auth-error">{pluginError}</div>{/if}{#if plugins.length}<div class="plugin-list">{#each plugins as plugin}<article class="plugin-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.plugin_id} · v{plugin.version}</span><div class="micro">API {plugin.manifest.api_version} · {plugin.manifest.permissions.length ? plugin.manifest.permissions.join(' · ') : 'NO PERMISSIONS'}</div></div><div class="bot-command-meta"><span class:good={plugin.enabled}>{plugin.enabled ? 'ENABLED' : 'DISABLED'}</span><button class="secondary-action" onclick={() => setPluginEnabled(plugin)} disabled={pluginBusy}>{plugin.enabled ? 'DISABLE' : 'ENABLE'}</button><button class="secondary-action danger" onclick={() => removePlugin(plugin)} disabled={pluginBusy}>REMOVE</button></div></article>{/each}</div>{:else}<div class="empty-row">No plugins are installed.</div>{/if}</div></section>
|
||||||
{:else if activeView === 'Bot'}
|
{:else if activeView === 'Bot'}
|
||||||
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} oninput={() => updateCommandVariableSuggestions(commandFormResponse)} maxlength="2000" placeholder="Hello ${user_name}"></textarea>{#if commandVariableSuggestions.length}<div class="command-variable-picker" role="listbox" aria-label="Command variables">{#each commandVariableSuggestions as variable}<button class="secondary-action" type="button" onclick={() => insertCommandVariable(variable)}>${`{${variable}}`}</button>{/each}</div>{/if}<label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
|
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} oninput={() => updateCommandVariableSuggestions(commandFormResponse)} maxlength="2000" placeholder="Hello ${user_name}"></textarea>{#if commandVariableSuggestions.length}<div class="command-variable-picker" role="listbox" aria-label="Command variables">{#each commandVariableSuggestions as variable}<button class="secondary-action" type="button" onclick={() => insertCommandVariable(variable)}>${`{${variable}}`}</button>{/each}</div>{/if}<label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
|
||||||
<section class="bot-page panel"><div class="panel-header"><span>BOT COMMANDS</span><button class="secondary" onclick={loadBotCommands}>REFRESH</button></div><div class="bot-page-body"><div class="micro">PUBLIC COMMAND CATALOG</div>{#if botCommandError}<div class="auth-error">{botCommandError}</div>{:else if botCommands.length}<div class="bot-command-list">{#each botCommands as command}<article class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.description}</span>{#if command.tags.length}<div class="micro">{command.tags.join(' · ')}</div>{/if}</div><div class="bot-command-meta"><span>{command.permission_level.toUpperCase()}</span>{#if command.cooldown_seconds}<span>{command.cooldown_seconds}s / {command.cooldown_scope}</span>{/if}</div></article>{/each}</div>{:else}<div class="empty-state"><strong>No enabled commands</strong><span>Create commands on the server to make them available here.</span></div>{/if}<div class="bot-secret-section"><div class="micro">SECRETS MANAGEMENT</div><div class="bot-secret-note">Values are encrypted and cannot be read after saving.</div><div class="secret-form"><input class="control" bind:value={secretName} maxlength="100" placeholder="Name" /><input class="control" bind:value={secretDescription} maxlength="500" placeholder="Description" /><input class="control" type="password" bind:value={secretValue} maxlength="65536" placeholder="Value" /><button class="primary" onclick={createSecret} disabled={!secretName || !secretValue}>ADD SECRET</button></div>{#if botSecrets.length}<div class="bot-secret-list">{#each botSecrets as secret}<div class="bot-secret-row"><div><strong>{secret.name}</strong><span>{secret.description || 'No description'}</span>{#if secret.used_by.length}<div class="micro">USED BY: {secret.used_by.join(', ')}</div>{/if}</div><button class="secondary-action" onclick={() => deleteSecret(secret.id)}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">There are no secrets yet.</div>{/if}{#if secretError}<div class="auth-error">{secretError}</div>{/if}</div></div></section>
|
<section class="bot-page panel"><div class="panel-header"><span>BOT COMMANDS</span><button class="secondary" onclick={loadBotCommands}>REFRESH</button></div><div class="bot-page-body"><div class="micro">PUBLIC COMMAND CATALOG</div>{#if botCommandError}<div class="auth-error">{botCommandError}</div>{:else if botCommands.length}<div class="bot-command-list">{#each botCommands as command}<article class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.description}</span>{#if command.tags.length}<div class="micro">{command.tags.join(' · ')}</div>{/if}</div><div class="bot-command-meta"><span>{command.permission_level.toUpperCase()}</span>{#if command.cooldown_seconds}<span>{command.cooldown_seconds}s / {command.cooldown_scope}</span>{/if}</div></article>{/each}</div>{:else}<div class="empty-state"><strong>No enabled commands</strong><span>Create commands on the server to make them available here.</span></div>{/if}<div class="bot-secret-section"><div class="micro">SECRETS MANAGEMENT</div><div class="bot-secret-note">Values are encrypted and cannot be read after saving.</div><div class="secret-form"><input class="control" bind:value={secretName} maxlength="100" placeholder="Name" /><input class="control" bind:value={secretDescription} maxlength="500" placeholder="Description" /><input class="control" type="password" bind:value={secretValue} maxlength="65536" placeholder="Value" /><button class="primary" onclick={createSecret} disabled={!secretName || !secretValue}>ADD SECRET</button></div>{#if botSecrets.length}<div class="bot-secret-list">{#each botSecrets as secret}<div class="bot-secret-row"><div><strong>{secret.name}</strong><span>{secret.description || 'No description'}</span>{#if secret.used_by.length}<div class="micro">USED BY: {secret.used_by.join(', ')}</div>{/if}</div><button class="secondary-action" onclick={() => deleteSecret(secret.id)}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">There are no secrets yet.</div>{/if}{#if secretError}<div class="auth-error">{secretError}</div>{/if}</div></div></section>
|
||||||
|
|||||||
@@ -1009,6 +1009,59 @@ label {
|
|||||||
.command-variable-picker .secondary-action {
|
.command-variable-picker .secondary-action {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
.plugin-drop-zone {
|
||||||
|
display: flex;
|
||||||
|
min-height: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px dashed var(--ui-line-focus);
|
||||||
|
background: var(--ui-surface);
|
||||||
|
color: var(--ed-fg-muted);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plugin-drop-zone.drop-active,
|
||||||
|
.plugin-drop-zone:hover {
|
||||||
|
border-color: var(--ed-accent);
|
||||||
|
background: var(--ui-surface-hi);
|
||||||
|
}
|
||||||
|
.plugin-drop-zone strong {
|
||||||
|
color: var(--ed-fg);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
.plugin-file-button {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plugin-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
margin-top: 16px;
|
||||||
|
background: var(--ui-divider);
|
||||||
|
}
|
||||||
|
.plugin-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--ui-surface);
|
||||||
|
}
|
||||||
|
.plugin-row > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.collaboration-state-panel .danger {
|
.collaboration-state-panel .danger {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user