Add web UI to enable/disable plugins

GET /api/plugins now lists all discovered plugins (enabled or not) with status;
PUT /api/plugins/<name> toggles enabled (persists to plugins.json). New
Settings > Plugins admin page with per-plugin toggles. Route changes apply on
next restart. Replaces CLI-only enable/disable for self-serve admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 15:01:14 -04:00
parent 4f1638bda0
commit 59ef0220b5
5 changed files with 200 additions and 20 deletions

View File

@@ -674,6 +674,15 @@ export const employeesApi = {
export const businessUnitsApi = businessunitsApi
// System Settings API
export const pluginsApi = {
list() {
return api.get('/plugins')
},
setEnabled(name, enabled) {
return api.put(`/plugins/${name}`, { enabled })
}
}
export const settingsApi = {
list(params = {}) {
return api.get('/settings', { params })

View File

@@ -92,6 +92,12 @@ export default [
component: () => import('../../views/settings/SystemSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/plugins',
name: 'plugins',
component: () => import('../../views/settings/PluginsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/auditlogs',
name: 'audit-logs',

View File

@@ -0,0 +1,140 @@
<template>
<div>
<div class="page-header">
<h2>Plugins</h2>
<span v-if="contractVersion" class="contract-badge">contract v{{ contractVersion }}</span>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<p class="hint">
Disabling a plugin removes its pages and API on the next app restart.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Version</th>
<th>Description</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
<tr v-for="p in plugins" :key="p.name">
<td>{{ p.name }}</td>
<td class="mono">{{ p.version }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<button
class="toggle-btn"
:class="{ active: p.enabled }"
:disabled="saving"
@click="toggle(p)"
>
<span class="toggle-slider"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="message" class="success-message">{{ message }}</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { pluginsApi } from '../../api'
const plugins = ref([])
const contractVersion = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const message = ref('')
onMounted(() => load())
async function load() {
loading.value = true
try {
const response = await pluginsApi.list()
plugins.value = response.data.data.plugins || []
contractVersion.value = response.data.data.contract_version || ''
} catch (err) {
error.value = 'Failed to load plugins'
} finally {
loading.value = false
}
}
async function toggle(p) {
error.value = ''
message.value = ''
saving.value = true
try {
const response = await pluginsApi.setEnabled(p.name, !p.enabled)
p.enabled = !p.enabled
message.value = response.data.message || 'Updated'
} catch (err) {
error.value = err.response?.data?.data?.error?.message || 'Failed to update plugin'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.hint {
color: var(--text-light);
margin-bottom: 1rem;
}
.contract-badge {
font-size: 0.8rem;
color: var(--text-light);
background: var(--bg);
padding: 0.25rem 0.6rem;
border-radius: 4px;
}
.toggle-btn {
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--border);
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle-btn.active {
background: var(--success);
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider {
transform: translateX(20px);
}
</style>

View File

@@ -75,6 +75,12 @@
<p>Configure integrations and system options</p>
</router-link>
<router-link to="/settings/plugins" class="settings-card">
<div class="card-icon"><Puzzle :size="28" /></div>
<h3>Plugins</h3>
<p>Enable or disable installed plugins</p>
</router-link>
<router-link to="/settings/auditlogs" class="settings-card">
<div class="card-icon"><FileText :size="28" /></div>
<h3>Audit Logs</h3>
@@ -91,7 +97,7 @@
</template>
<script setup>
import { Factory, MapPin, Tag, Package, Droplets, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
</script>
<style scoped>

View File

@@ -1,9 +1,9 @@
"""Plugin introspection API - what plugins are loaded and their contracts."""
"""Plugin introspection + enable/disable API."""
from flask import Blueprint, current_app
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from shopdb.utils.responses import success_response
from shopdb.utils.responses import success_response, error_response, ErrorCodes
plugins_bp = Blueprint('plugins', __name__)
@@ -11,27 +11,46 @@ plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_plugins():
"""List loaded plugins with their manifest metadata + the framework
contract version they were checked against."""
"""List all discovered plugins (enabled or not) with their metadata and
the framework contract version."""
from shopdb import __contract_version__
pm = current_app.extensions.get('plugin_manager')
plugins = []
if pm:
for name, plugin in pm.get_all_plugins().items():
meta = plugin.meta
plugins.append({
'name': meta.name,
'version': meta.version,
'description': meta.description,
'author': meta.author,
'core_version': meta.core_version,
'api_prefix': meta.api_prefix,
'dependencies': meta.dependencies,
})
plugins = pm.discover_available() if pm else []
plugins.sort(key=lambda p: p['name'])
return success_response({
'contract_version': __contract_version__,
'count': len(plugins),
'plugins': plugins,
})
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
def set_plugin_enabled(name: str):
"""Enable or disable a plugin. Takes effect on the next app restart for
route/navigation changes."""
data = request.get_json() or {}
if 'enabled' not in data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required')
pm = current_app.extensions.get('plugin_manager')
if not pm:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Plugin manager unavailable', http_code=500)
want = bool(data['enabled'])
ok = pm.enable_plugin(name) if want else pm.disable_plugin(name)
if not ok:
# enable/disable refused (unknown plugin, or a dependency conflict)
return error_response(
ErrorCodes.CONFLICT,
f"Could not {'enable' if want else 'disable'} '{name}' "
f"(check it exists and dependencies allow it)",
http_code=409
)
return success_response(
{'name': name, 'enabled': want},
message=f"Plugin {'enabled' if want else 'disabled'} "
f"(restart to apply route changes)"
)