<template>
<div class="admin-settings">
<NcLoadingIcon v-if="loading" :size="64" class="loading-icon" />
<NcNoteCard v-else-if="error" type="error">
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
<p>{{ error }}</p>
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
<NcButton type="primary" @click="retryConnection">
<template #icon>
<Refresh :size="20" />
</template>
{{ t('astrolabe', 'Retry Connection') }}
</NcButton>
</NcNoteCard>
<template v-else>
<!-- Service Status -->
<div class="admin-section">
<h3>{{ t('astrolabe', 'Service Status') }}</h3>
<div class="status-card">
<p><strong>{{ t('astrolabe', 'Version') }}:</strong> {{ serverStatus?.version || 'Unknown' }}</p>
<p v-if="serverStatus?.uptime_seconds">
<strong>{{ t('astrolabe', 'Uptime') }}:</strong> {{ formatUptime(serverStatus.uptime_seconds) }}
</p>
<p>
<strong>{{ t('astrolabe', 'Semantic Search') }}:</strong>
<span v-if="vectorSyncEnabled" class="status-badge status-enabled">
{{ t('astrolabe', 'Enabled') }}
</span>
<span v-else class="status-badge status-disabled">
{{ t('astrolabe', 'Disabled') }}
</span>
</p>
</div>
</div>
<!-- Indexing Metrics -->
<div v-if="vectorSyncEnabled && vectorSyncStatus" class="admin-section">
<h3>{{ t('astrolabe', 'Indexing Metrics') }}</h3>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Status') }}</div>
<div class="metric-value" :class="`status-${vectorSyncStatus.status}`">
{{ vectorSyncStatus.status }}
</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Indexed Documents') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.indexed_documents) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Pending Documents') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.pending_documents) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('astrolabe', 'Processing Rate') }}</div>
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
</div>
</div>
<NcButton type="secondary" @click="refreshStatus">
<template #icon>
<Refresh :size="20" />
</template>
{{ t('astrolabe', 'Refresh Status') }}
</NcButton>
</div>
<!-- Webhook Management -->
<div v-if="vectorSyncEnabled" class="admin-section">
<h3>{{ t('astrolabe', 'Webhook Management') }}</h3>
<p class="section-description">
{{ t('astrolabe', 'Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.') }}
</p>
<div v-if="webhooksLoading" class="loading-indicator">
<NcLoadingIcon :size="32" />
<p>{{ t('astrolabe', 'Loading webhook presets...') }}</p>
</div>
<NcNoteCard v-else-if="webhooksError" type="warning">
<p><strong>{{ t('astrolabe', 'Authorization Required') }}</strong></p>
<p v-if="webhooksError.includes('authorization')">
{{ t('astrolabe', 'To manage webhooks, you must first authorize Astrolabe with the MCP server in your Personal Settings.') }}
</p>
<p v-else>{{ webhooksError }}</p>
<div class="webhook-auth-actions">
<NcButton type="primary" @click="openPersonalSettings">
{{ t('astrolabe', 'Go to Personal Settings') }}
</NcButton>
</div>
</NcNoteCard>
<template v-else>
<div v-if="webhookPresets.length === 0" class="empty-state">
<NcNoteCard type="info">
<p>{{ t('astrolabe', 'No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.') }}</p>
</NcNoteCard>
</div>
<div v-else class="webhook-presets-grid">
<div v-for="preset in webhookPresets" :key="preset.id" class="webhook-preset-card">
<div class="preset-header">
<h4>{{ preset.name }}</h4>
<span :class="`preset-status preset-status-${preset.enabled ? 'enabled' : 'disabled'}`">
{{ preset.enabled ? t('astrolabe', 'Enabled') : t('astrolabe', 'Disabled') }}
</span>
</div>
<p class="preset-description">{{ preset.description }}</p>
<div class="preset-meta">
<span class="preset-app">{{ t('astrolabe', 'App') }}: {{ preset.app }}</span>
<span class="preset-events">{{ preset.events.length }} {{ t('astrolabe', 'events') }}</span>
</div>
<div class="preset-actions">
<NcButton
:type="preset.enabled ? 'secondary' : 'primary'"
:disabled="preset.toggling"
@click="toggleWebhookPreset(preset)">
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
</NcButton>
</div>
</div>
</div>
<NcNoteCard type="info" class="webhook-info">
<p><strong>{{ t('astrolabe', 'How Webhooks Work') }}</strong></p>
<ul>
<li>{{ t('astrolabe', 'Enable a preset to register webhooks for that app with the MCP server') }}</li>
<li>{{ t('astrolabe', 'When content changes in Nextcloud, webhooks notify the MCP server instantly') }}</li>
<li>{{ t('astrolabe', 'The MCP server updates its vector index in real-time for semantic search') }}</li>
<li>{{ t('astrolabe', 'Disable a preset to stop receiving updates for that app') }}</li>
</ul>
</NcNoteCard>
<NcNoteCard type="warning" class="webhook-requirements">
<p><strong>{{ t('astrolabe', 'Requirements') }}</strong></p>
<ul>
<li>{{ t('astrolabe', 'The webhook_listeners app must be installed and enabled in Nextcloud') }}</li>
<li>{{ t('astrolabe', 'The MCP server must be reachable from your Nextcloud instance') }}</li>
<li>{{ t('astrolabe', 'You must have authorized Astrolabe with the MCP server (see Personal Settings)') }}</li>
</ul>
</NcNoteCard>
</template>
</div>
<!-- Search Settings -->
<div v-if="vectorSyncEnabled" class="admin-section">
<h3>{{ t('astrolabe', 'AI Search Provider Settings') }}</h3>
<p class="section-description">
{{ t('astrolabe', 'Configure the default search parameters for the AI Search provider in Nextcloud unified search.') }}
</p>
<div class="settings-form">
<NcSelect
v-model="settings.algorithm"
:options="algorithmOptions"
:label="t('astrolabe', 'Search Algorithm')"
class="form-field" />
<p class="help-text">
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
</p>
<NcSelect
v-model="settings.fusion"
:options="fusionOptions"
:label="t('astrolabe', 'Fusion Method')"
class="form-field" />
<p class="help-text">
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
</p>
<div class="form-field">
<label>{{ t('astrolabe', 'Minimum Score Threshold') }}: {{ settings.scoreThreshold }}%</label>
<input
v-model="settings.scoreThreshold"
type="range"
min="0"
max="100"
step="5"
class="score-slider" />
<p class="help-text">
{{ t('astrolabe', 'Filter out results below this relevance score. Set to 0 to show all results.') }}
</p>
</div>
<NcTextField
:value="settings.limit"
:label="t('astrolabe', 'Maximum Results')"
type="number"
:min="5"
:max="100"
:step="5"
class="form-field"
@update:value="settings.limit = Number($event)" />
<p class="help-text">
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
</p>
<div class="form-actions">
<NcButton type="primary" :disabled="saving" @click="saveSettings">
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
</NcButton>
</div>
</div>
</div>
<!-- Documentation -->
<div class="admin-section">
<h3>{{ t('astrolabe', 'Documentation') }}</h3>
<ul class="doc-links">
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
{{ t('astrolabe', 'Configuration Guide') }}
</a>
</li>
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
{{ t('astrolabe', 'GitHub Repository') }}
</a>
</li>
</ul>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import {
NcLoadingIcon,
NcNoteCard,
NcButton,
NcSelect,
NcTextField,
} from '@nextcloud/vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'
// Reactive state
const loading = ref(true)
const error = ref(null)
const serverStatus = ref(null)
const vectorSyncStatus = ref(null)
const vectorSyncEnabled = ref(false)
const saving = ref(false)
// Webhook management state
const webhooksLoading = ref(false)
const webhooksError = ref(null)
const webhookPresets = ref([])
// Load initial state from PHP
const initialData = loadState('astrolabe', 'admin-config', {})
const settings = ref(initialData.searchSettings || {
algorithm: 'hybrid',
fusion: 'rrf',
scoreThreshold: 0,
limit: 20,
})
// Computed properties
const algorithmOptions = computed(() => [
{ id: 'hybrid', label: t('astrolabe', 'Hybrid (Recommended)') },
{ id: 'semantic', label: t('astrolabe', 'Semantic Only') },
{ id: 'bm25', label: t('astrolabe', 'Keyword (BM25) Only') },
])
const fusionOptions = computed(() => [
{ id: 'rrf', label: t('astrolabe', 'RRF - Reciprocal Rank Fusion (Recommended)') },
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
])
// Methods
async function loadServerStatus() {
loading.value = true
error.value = null
try {
// Fetch server status asynchronously
const [statusResponse, syncResponse] = await Promise.all([
axios.get(generateUrl('/apps/astrolabe/api/admin/server-status')),
axios.get(generateUrl('/apps/astrolabe/api/admin/vector-status')),
])
if (statusResponse.data.success) {
serverStatus.value = statusResponse.data.status
vectorSyncEnabled.value = statusResponse.data.status?.vector_sync_enabled ?? false
}
if (syncResponse.data.success) {
vectorSyncStatus.value = syncResponse.data.status
}
} catch (err) {
console.error('Failed to load server status:', err)
error.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
} finally {
loading.value = false
}
}
async function refreshStatus() {
await loadServerStatus()
showSuccess(t('astrolabe', 'Status refreshed'))
}
async function retryConnection() {
// Clear error and retry loading server status
error.value = null
loading.value = true
await loadServerStatus()
}
async function saveSettings() {
saving.value = true
try {
const response = await axios.post(
generateUrl('/apps/astrolabe/api/admin/search-settings'),
settings.value,
{ headers: { 'Content-Type': 'application/json' } },
)
if (response.data.success) {
showSuccess(t('astrolabe', 'Settings saved successfully'))
}
} catch (err) {
console.error('Failed to save settings:', err)
showError(t('astrolabe', 'Failed to save settings'))
} finally {
saving.value = false
}
}
async function loadWebhookPresets() {
webhooksLoading.value = true
webhooksError.value = null
try {
const response = await axios.get(generateUrl('/apps/astrolabe/api/admin/webhooks/presets'))
if (response.data.success) {
// Convert presets object to array with IDs
const presetsObj = response.data.presets
webhookPresets.value = Object.keys(presetsObj).map(id => ({
id,
...presetsObj[id],
toggling: false,
}))
} else {
webhooksError.value = response.data.error || t('astrolabe', 'Failed to load webhook presets')
}
} catch (err) {
console.error('Failed to load webhook presets:', err)
webhooksError.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
} finally {
webhooksLoading.value = false
}
}
async function toggleWebhookPreset(preset) {
preset.toggling = true
const endpoint = preset.enabled
? `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/disable`
: `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/enable`
try {
const response = await axios.post(generateUrl(endpoint))
if (response.data.success) {
// Toggle the enabled state
preset.enabled = !preset.enabled
showSuccess(response.data.message || (preset.enabled ? t('astrolabe', 'Webhook preset enabled') : t('astrolabe', 'Webhook preset disabled')))
} else {
showError(response.data.error || t('astrolabe', 'Failed to toggle webhook preset'))
}
} catch (err) {
console.error('Failed to toggle webhook preset:', err)
showError(err.response?.data?.error || err.message || t('astrolabe', 'Network error'))
} finally {
preset.toggling = false
}
}
function openPersonalSettings() {
window.location.href = generateUrl('/settings/user/astrolabe')
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return t('astrolabe', '{hours} hours, {minutes} minutes', { hours, minutes })
}
function formatNumber(value, decimals = 0) {
if (value === undefined || value === null) return '0'
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
}
// Lifecycle hooks
onMounted(async () => {
await loadServerStatus()
// Load webhook presets if vector sync is enabled
if (vectorSyncEnabled.value) {
await loadWebhookPresets()
}
})
</script>
<style scoped lang="scss">
.admin-settings {
padding: 20px;
max-width: 900px;
// Fix NcNoteCard icon sizing issues in Vue 3/@nextcloud/vue 9
:deep(.notecard) {
max-width: 100%;
margin-bottom: 16px;
.notecard__icon {
flex-shrink: 0;
width: 24px;
height: 24px;
svg {
width: 24px;
height: 24px;
}
}
}
}
.loading-icon {
margin: 40px auto;
display: block;
}
.admin-section {
margin-bottom: 32px;
h3 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
}
}
.section-description {
color: var(--color-text-maxcontrast);
margin-bottom: 16px;
}
.help-text {
color: var(--color-text-maxcontrast);
font-size: 13px;
margin-top: 8px;
}
.status-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
p {
margin: 8px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
&.status-enabled {
background: var(--color-success);
color: white;
}
&.status-disabled {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
text-align: center;
}
.metric-label {
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 8px;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: var(--color-main-text);
&.status-idle {
color: var(--color-success);
}
&.status-syncing {
color: var(--color-warning);
}
&.status-error {
color: var(--color-error);
}
}
.settings-form {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
}
.form-field {
margin-bottom: 20px;
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-main-text);
}
}
.score-slider {
width: 100%;
accent-color: var(--color-primary-element);
}
.form-actions {
display: flex;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.doc-links {
list-style: none;
padding: 0;
li {
margin-bottom: 8px;
}
a {
color: var(--color-primary-element);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
// Webhook management styles
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
color: var(--color-text-maxcontrast);
}
.webhook-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.webhook-preset-card {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 16px;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--color-primary-element);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.preset-status {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
&.preset-status-enabled {
background: var(--color-success);
color: white;
}
&.preset-status-disabled {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.preset-description {
color: var(--color-text-maxcontrast);
font-size: 14px;
margin: 0 0 12px 0;
line-height: 1.5;
}
.preset-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-maxcontrast);
margin-bottom: 12px;
.preset-app {
font-weight: 500;
}
}
.preset-actions {
display: flex;
justify-content: flex-end;
}
}
.webhook-info,
.webhook-requirements {
margin-top: 16px;
ul {
margin: 8px 0 0 0;
padding-left: 20px;
li {
margin: 4px 0;
}
}
}
</style>