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:
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
140
frontend/src/views/settings/PluginsList.vue
Normal file
140
frontend/src/views/settings/PluginsList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user