feat: Migrate to vue 3

This commit is contained in:
Chris Coutinho
2025-12-23 05:46:49 +01:00
parent d7c99fcc69
commit 4248b67b2e
15 changed files with 1881 additions and 2777 deletions
+7 -1
View File
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
### Authentication Modes
The server supports two authentication modes:
The server supports three authentication modes:
**Single-User Mode (BasicAuth):**
- One set of credentials shared by all MCP clients
@@ -113,6 +113,12 @@ The server supports two authentication modes:
- More secure: tokens expire, credentials never shared with server
- Best for: Teams, multi-user deployments, production environments with multiple users
**Hybrid Mode (Multi-User BasicAuth + OAuth):**
- MCP clients use BasicAuth (simple, stateless)
- Admin operations use OAuth (webhooks, background sync)
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
## Semantic Search
+87
View File
@@ -140,6 +140,93 @@ Basic Authentication uses username and password credentials directly.
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
## Hybrid Authentication (Multi-User BasicAuth + OAuth)
When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the server operates in **hybrid authentication mode**. This provides the simplicity of BasicAuth for normal operations with the security of OAuth for administrative functions.
### Authentication Domains
**MCP Operations** (Tools, Resources):
- **Auth Method**: BasicAuth (HTTP Basic username/password)
- **Characteristics**:
- Stateless - no token storage
- Simple configuration
- Direct credential validation against Nextcloud
- Credentials passed per-request in Authorization header
- **Used For**: MCP tool calls from Claude, MCP client operations
**Management APIs** (Webhooks, Admin UI):
- **Auth Method**: OAuth bearer tokens
- **Characteristics**:
- Per-user authorization via OAuth consent flow
- Refresh tokens stored for background operations
- Token validation via UnifiedTokenVerifier
- Explicit user consent required
- **Used For**: Astrolabe admin UI, webhook management, vector sync operations
### Configuration
```env
# Enable multi-user BasicAuth
ENABLE_MULTI_USER_BASIC_AUTH=true
# Enable hybrid mode (OAuth provisioning for management APIs)
ENABLE_OFFLINE_ACCESS=true
# Enable background sync (required for hybrid mode currently)
VECTOR_SYNC_ENABLED=true
# Encryption key for refresh token storage
TOKEN_ENCRYPTION_KEY=<base64-encoded-key>
# Nextcloud connection
NEXTCLOUD_HOST=https://cloud.example.com
# OAuth credentials (optional - uses DCR if not set)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
### OAuth Provisioning Flow
1. Admin opens Astrolabe admin settings in Nextcloud
2. Clicks "Authorize" to enable webhook management
3. Redirected to `/oauth/authorize-nextcloud` on MCP server
4. MCP server redirects to Nextcloud OAuth consent page
5. Admin grants OAuth consent (scopes: `openid`, `profile`, `offline_access`)
6. Redirected back to `/oauth/callback` on MCP server
7. MCP server stores refresh token (encrypted)
8. Admin can now manage webhooks from Astrolabe UI
### Benefits
- **Simple MCP client setup**: Use BasicAuth (no OAuth complexity for end users)
- **Secure background operations**: Webhooks use per-user OAuth tokens (no shared credentials)
- **Explicit authorization**: Admins must explicitly grant OAuth consent for webhook operations
- **Per-user isolation**: Each admin's webhook operations use their own refresh token
### Trade-offs
- **Two auth systems**: More complex server configuration than pure BasicAuth or OAuth
- **OAuth setup required**: Admins must complete OAuth flow before managing webhooks
- **Token storage**: Requires database and encryption key for refresh tokens
### Comparison
| Feature | Pure BasicAuth | Hybrid Mode | Pure OAuth |
|---------|---------------|-------------|------------|
| MCP Operations | BasicAuth | BasicAuth | OAuth Bearer Token |
| Management API | N/A | OAuth Bearer Token | OAuth Bearer Token |
| Webhook Operations | N/A | OAuth Refresh Token | OAuth Refresh Token |
| MCP Client Setup | Simple | Simple | Complex (PKCE flow) |
| Admin UI Auth | N/A | OAuth Consent | OAuth Login |
| Token Storage | None | Refresh tokens only | All tokens |
| Deployment Complexity | Low | Medium | High |
### See Also
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
## Mode Detection
The server automatically detects the authentication mode:
+250 -12
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import os
import time
@@ -42,6 +44,7 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
Settings,
get_document_processor_config,
get_settings,
)
@@ -1012,6 +1015,160 @@ async def setup_oauth_config():
)
async def setup_oauth_config_for_multi_user_basic(
settings: Settings,
client_id: str,
client_secret: str,
) -> tuple[UnifiedTokenVerifier, RefreshTokenStorage | None, str, str]:
"""
Setup minimal OAuth configuration for multi-user BasicAuth mode.
This is a lightweight version of setup_oauth_config() that:
- Performs OIDC discovery to get endpoints
- Creates UnifiedTokenVerifier for management API token validation
- Creates RefreshTokenStorage for webhook token storage
- Skips OAuth client creation (not needed for BasicAuth background sync)
- Skips AuthSettings creation (not needed for BasicAuth MCP operations)
This enables hybrid authentication mode where:
- MCP operations use BasicAuth (stateless, simple)
- Management APIs use OAuth bearer tokens (secure, per-user)
- Background operations use OAuth refresh tokens (webhook sync)
Args:
settings: Application settings
client_id: OAuth client ID (from DCR or static config)
client_secret: OAuth client secret
Returns:
Tuple of (token_verifier, refresh_token_storage, client_id, client_secret)
Raises:
ValueError: If NEXTCLOUD_HOST is not set
httpx.HTTPError: If OIDC discovery fails
"""
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST is required for OAuth infrastructure setup")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OIDC discovery URL (always Nextcloud integrated mode for multi-user BasicAuth)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
logger.info(
f"Performing OIDC discovery for multi-user BasicAuth hybrid mode: {discovery_url}"
)
# Perform OIDC discovery
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("✓ OIDC discovery successful (multi-user BasicAuth)")
# Extract OIDC endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
# For multi-user BasicAuth, always assume Nextcloud integrated mode
# and rewrite endpoints to use internal URL for backend access
if jwks_uri:
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
jwks_uri = internal_jwks_uri
if introspection_uri:
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
introspection_uri = internal_introspection_uri
if userinfo_uri:
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
userinfo_uri = internal_userinfo_uri
logger.info("OIDC endpoints configured for management API:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
logger.info(f" Introspection: {introspection_uri}")
# Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
# Handle public issuer override (for JWT validation)
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL override for JWT validation: {public_issuer}"
)
client_issuer = public_issuer
else:
client_issuer = issuer
# Update settings with discovered values for UnifiedTokenVerifier
if not settings.oidc_client_id:
settings.oidc_client_id = client_id
if not settings.oidc_client_secret:
settings.oidc_client_secret = client_secret
if not settings.jwks_uri:
settings.jwks_uri = jwks_uri
if not settings.introspection_uri:
settings.introspection_uri = introspection_uri
if not settings.userinfo_uri:
settings.userinfo_uri = userinfo_uri
if not settings.oidc_issuer:
settings.oidc_issuer = client_issuer
if not settings.nextcloud_mcp_server_url:
settings.nextcloud_mcp_server_url = mcp_server_url
if not settings.nextcloud_resource_uri:
settings.nextcloud_resource_uri = nextcloud_resource_uri
# Create Unified Token Verifier for management API authentication
token_verifier = UnifiedTokenVerifier(settings)
logger.info("✓ Token verifier created for management API (hybrid mode)")
if introspection_uri:
logger.info(" Opaque token introspection enabled (RFC 7662)")
if jwks_uri:
logger.info(" JWT signature verification enabled (JWKS)")
# Initialize refresh token storage for background operations
refresh_token_storage = None
if settings.enable_offline_access:
try:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
logger.warning(
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
"Refresh tokens will NOT be stored. Generate a key with:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
else:
refresh_token_storage = RefreshTokenStorage.from_env()
await refresh_token_storage.initialize()
logger.info(
"✓ Refresh token storage initialized for background operations (hybrid mode)"
)
except Exception as e:
logger.error(f"Failed to initialize refresh token storage: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Continuing without refresh token storage - webhook management may be limited"
)
logger.info(
"OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
return (token_verifier, refresh_token_storage, client_id, client_secret)
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
@@ -1043,6 +1200,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
else DeploymentMode.SELF_HOSTED
)
# Log hybrid authentication status for multi-user BasicAuth with offline access
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
logger.info(
"🔄 Hybrid authentication mode will be enabled:\n"
" - MCP operations: BasicAuth (stateless, credentials per-request)\n"
" - Management APIs: OAuth bearer tokens (secure, per-user)\n"
" - Background operations: OAuth refresh tokens (webhook sync)"
)
# Setup Prometheus metrics (always enabled by default)
if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port)
@@ -1070,6 +1236,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
# to avoid async context issues
multi_user_basic_oauth_creds: tuple[str, str] | None = None
multi_user_token_verifier: UnifiedTokenVerifier | None = None
multi_user_refresh_storage: RefreshTokenStorage | None = None
if (
mode == AuthMode.MULTI_USER_BASIC
@@ -1135,6 +1303,42 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Run DCR synchronously before uvicorn starts
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
# Setup OAuth infrastructure for management APIs and background operations
# This creates the UnifiedTokenVerifier needed by management.py and
# RefreshTokenStorage for webhook token persistence
if multi_user_basic_oauth_creds:
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
logger.info(
"Setting up OAuth infrastructure for management APIs (hybrid mode)..."
)
try:
(
multi_user_token_verifier,
multi_user_refresh_storage,
_,
_,
) = anyio.run(
setup_oauth_config_for_multi_user_basic,
settings,
sync_client_id,
sync_client_secret,
)
logger.info(
"✓ OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
except Exception as e:
logger.error(f"Failed to setup OAuth infrastructure: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Management API will be unavailable. "
"Webhook management from Astrolabe admin UI will not work."
)
# Set to None to indicate failure
multi_user_token_verifier = None
multi_user_refresh_storage = None
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
logger.info("Configuring MCP server for OAuth mode")
@@ -1410,11 +1614,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
# This allows Astrolabe to use management APIs with OAuth bearer tokens
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
# Check if we have OAuth credentials from DCR
if multi_user_basic_oauth_creds:
# Check if we have OAuth credentials AND infrastructure from setup
if (
multi_user_basic_oauth_creds
and multi_user_token_verifier is not None
):
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
# Create minimal oauth_context for management API authentication
# Create oauth_context for management API authentication
nextcloud_host_for_context = settings.nextcloud_host
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
@@ -1425,9 +1632,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
oauth_context_dict = {
"storage": basic_auth_storage,
# Use OAuth refresh token storage if available, fallback to basic_auth_storage
"storage": multi_user_refresh_storage or basic_auth_storage,
"oauth_client": None, # Not needed for management APIs
"token_verifier": None, # Will be set when token broker is created
"token_verifier": multi_user_token_verifier, # FIXED: Now has real verifier!
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
@@ -1441,7 +1649,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
}
app.state.oauth_context = oauth_context_dict
logger.info(
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
f"OAuth context initialized for management APIs (hybrid mode, client_id={sync_client_id[:16]}...)"
)
elif multi_user_basic_oauth_creds and multi_user_token_verifier is None:
logger.warning(
"OAuth infrastructure setup failed - management API will be unavailable. "
"This is expected if OIDC discovery failed or token verifier creation failed. "
"Webhook management from Astrolabe admin UI will not work."
)
else:
logger.warning(
"OAuth credentials not available - management API will be unavailable. "
"This is expected if DCR failed or static credentials were not provided. "
"Webhook management from Astrolabe admin UI will not work."
)
# Also share with browser_app for webhook routes
@@ -2028,7 +2248,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
if oauth_enabled:
# Determine if OAuth provisioning is available
# This is true for:
# 1. OAuth modes (primary auth method for MCP operations)
# 2. Multi-user BasicAuth with offline access (hybrid mode)
oauth_provisioning_available = oauth_enabled or (
mode == AuthMode.MULTI_USER_BASIC
and settings.enable_offline_access
and multi_user_token_verifier is not None # Ensure OAuth setup succeeded
)
if oauth_provisioning_available:
logger.info(
f"OAuth provisioning routes enabled for mode: {mode.value} "
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
)
# Import OAuth routes (ADR-004 Progressive Consent)
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
@@ -2091,10 +2325,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add unified OAuth callback endpoint supporting both flows
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_authorize_nextcloud,
@@ -2124,9 +2354,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
)
logger.info(
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2, legacy)"
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2)"
)
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
if oauth_enabled:
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add browser OAuth login routes (OAuth mode only)
if oauth_enabled:
from nextcloud_mcp_server.auth.browser_oauth_routes import (
+10
View File
@@ -74,6 +74,16 @@ return [
],
// Admin settings routes
[
'name' => 'api#serverStatus',
'url' => '/api/admin/server-status',
'verb' => 'GET',
],
[
'name' => 'api#adminVectorStatus',
'url' => '/api/admin/vector-status',
'verb' => 'GET',
],
[
'name' => 'api#saveSearchSettings',
'url' => '/api/admin/search-settings',
+48
View File
@@ -254,6 +254,54 @@ class ApiController extends Controller {
]);
}
/**
* Get MCP server status.
*
* Admin-only endpoint for admin settings page.
* Returns server version, uptime, and vector sync availability.
*
* @return JSONResponse
*/
public function serverStatus(): JSONResponse {
$status = $this->client->getStatus();
if (isset($status['error'])) {
return new JSONResponse([
'success' => false,
'error' => $status['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse([
'success' => true,
'status' => $status
]);
}
/**
* Get vector sync status for admin.
*
* Admin-only endpoint for admin settings page.
* Returns indexing metrics and sync status.
*
* @return JSONResponse
*/
public function adminVectorStatus(): JSONResponse {
$status = $this->client->getVectorSyncStatus();
if (isset($status['error'])) {
return new JSONResponse([
'success' => false,
'error' => $status['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse([
'success' => true,
'status' => $status
]);
}
/**
* Save admin search settings.
*
+7 -34
View File
@@ -47,11 +47,7 @@ class Admin implements ISettings {
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
// Fetch data from MCP server
$serverStatus = $this->client->getStatus();
$vectorSyncStatus = $this->client->getVectorSyncStatus();
// Get configuration from config.php
// Get configuration from config.php (local, fast)
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
@@ -59,21 +55,6 @@ class Admin implements ISettings {
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
$clientSecretConfigured = !empty($clientSecret);
// Check for server connection error
if (isset($serverStatus['error'])) {
return new TemplateResponse(
Application::APP_ID,
'settings/error',
[
'error' => 'Cannot connect to MCP server',
'details' => $serverStatus['error'],
'server_url' => $serverUrl,
'help_text' => 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.',
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Load search settings from app config
$searchSettings = [
'algorithm' => $this->config->getAppValue(
@@ -98,27 +79,19 @@ class Admin implements ISettings {
),
];
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('server-data', [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
// Provide initial state for Vue.js frontend
// MCP server data will be fetched asynchronously by Vue component
$this->initialState->provideInitialState('admin-config', [
'config' => [
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
'clientIdConfigured' => $clientIdConfigured,
'clientSecretConfigured' => $clientSecretConfigured,
],
'searchSettings' => $searchSettings,
]);
$parameters = [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
'clientIdConfigured' => $clientIdConfigured,
'clientSecretConfigured' => $clientSecretConfigured,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'searchSettings' => $searchSettings,
];
$parameters = [];
return new TemplateResponse(
Application::APP_ID,
+581 -1991
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -19,6 +19,8 @@
],
"dependencies": {
"@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^7.2.0",
"@nextcloud/initial-state": "^3.0.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
@@ -32,8 +34,8 @@
"@nextcloud/browserslist-config": "3.1.2",
"@nextcloud/eslint-config": "8.4.2",
"@nextcloud/stylelint-config": "3.1.1",
"@nextcloud/vite-config": "1.7.2",
"@vitejs/plugin-vue": "^6.0.3",
"sass-embedded": "^1.97.1",
"terser": "5.44.1",
"vite": "7.2.7"
}
+7 -5
View File
@@ -48,10 +48,11 @@
<div class="mcp-search-card">
<div class="mcp-search-row">
<NcTextField
v-model:value="query"
:value="query"
:label="t('astrolabe', 'Search query')"
:placeholder="t('astrolabe', 'Enter your search query...')"
class="mcp-search-input"
@update:value="query = $event"
@keyup.enter="performSearch" />
<NcSelect
@@ -104,10 +105,11 @@
<div class="mcp-option-group">
<label>{{ t('astrolabe', 'Result Limit') }}</label>
<NcTextField
v-model:value="limit"
:value="limit"
type="number"
:min="1"
:max="100" />
:max="100"
@update:value="limit = Number($event)" />
</div>
<div class="mcp-option-group">
@@ -152,9 +154,9 @@
<div class="mcp-viz-header">
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
<NcCheckboxRadioSwitch
v-model:checked="showQueryPoint"
:checked="showQueryPoint"
type="switch"
@update:checked="updatePlot">
@update:checked="showQueryPoint = $event; updatePlot()">
{{ t('astrolabe', 'Show query point') }}
</NcCheckboxRadioSwitch>
</div>
+11 -238
View File
@@ -1,245 +1,18 @@
/**
* Admin settings page JavaScript for Astrolabe.
* Admin settings page Vue app for Astrolabe.
*
* Handles:
* - Loading webhook presets
* - Enabling/disabling webhook presets
* - Search settings form submission
* Mounts the AdminSettings Vue component for async loading
* and improved UX.
*/
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import './styles/settings.css'
import { createApp } from 'vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import AdminSettings from './components/admin/AdminSettings.vue'
document.addEventListener('DOMContentLoaded', () => {
// Initialize search settings form
initSearchSettingsForm()
const app = createApp(AdminSettings)
// Initialize webhook management (only if webhook section exists)
if (document.getElementById('webhook-presets')) {
initWebhookManagement()
}
})
// Add translation methods globally
app.config.globalProperties.t = t
app.config.globalProperties.n = n
/**
* Initialize search settings form handling.
*/
function initSearchSettingsForm() {
const form = document.getElementById('astrolabe-search-settings-form')
if (!form) return
const scoreThresholdInput = document.getElementById('search-score-threshold')
const scoreThresholdValue = document.getElementById('score-threshold-value')
// Update score threshold display when slider changes
if (scoreThresholdInput && scoreThresholdValue) {
scoreThresholdInput.addEventListener('input', (e) => {
scoreThresholdValue.textContent = e.target.value + '%'
})
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(form)
const data = {
algorithm: formData.get('algorithm'),
fusion: formData.get('fusion'),
scoreThreshold: parseInt(formData.get('scoreThreshold')),
limit: parseInt(formData.get('limit')),
}
const statusEl = document.getElementById('search-settings-status')
if (statusEl) {
statusEl.textContent = 'Saving...'
statusEl.className = 'mcp-status-message'
}
try {
const response = await axios.post(
generateUrl('/apps/astrolabe/api/admin/search-settings'),
data,
{ headers: { 'Content-Type': 'application/json' } },
)
if (response.data.success) {
if (statusEl) {
statusEl.textContent = '✓ Settings saved'
statusEl.className = 'mcp-status-message success'
setTimeout(() => {
statusEl.textContent = ''
}, 3000)
}
}
} catch (error) {
console.error('Failed to save search settings:', error)
if (statusEl) {
statusEl.textContent = '✗ Failed to save'
statusEl.className = 'mcp-status-message error'
}
}
})
}
/**
* Initialize webhook management UI.
*/
async function initWebhookManagement() {
const container = document.getElementById('webhook-presets-container')
if (!container) return
try {
// Load webhook presets from API
const response = await axios.get(
generateUrl('/apps/astrolabe/api/admin/webhooks/presets'),
)
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to load presets')
}
const presets = response.data.presets
renderWebhookPresets(container, presets)
} catch (error) {
console.error('Failed to load webhook presets:', error)
container.innerHTML = `
<div class="notecard notecard-error">
<p><strong>Error loading webhook presets:</strong></p>
<p>${error.message || 'Unknown error'}</p>
</div>
`
}
}
/**
* Render webhook preset cards.
*
* @param {HTMLElement} container Container element
* @param {object} presets Preset configurations
*/
function renderWebhookPresets(container, presets) {
const presetIds = Object.keys(presets)
if (presetIds.length === 0) {
container.innerHTML = `
<div class="notecard notecard-info">
<p>No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.</p>
</div>
`
return
}
// Create preset cards grid
const grid = document.createElement('div')
grid.className = 'mcp-preset-grid'
presetIds.forEach(presetId => {
const preset = presets[presetId]
const card = createPresetCard(presetId, preset)
grid.appendChild(card)
})
container.innerHTML = ''
container.appendChild(grid)
}
/**
* Create a webhook preset card.
*
* @param {string} presetId Preset ID
* @param {object} preset Preset configuration
* @return {HTMLElement} Card element
*/
function createPresetCard(presetId, preset) {
const card = document.createElement('div')
card.className = 'mcp-preset-card'
card.dataset.presetId = presetId
const statusClass = preset.enabled ? 'enabled' : 'disabled'
const statusText = preset.enabled ? 'Enabled' : 'Disabled'
const buttonText = preset.enabled ? 'Disable' : 'Enable'
const buttonClass = preset.enabled ? 'secondary' : 'primary'
card.innerHTML = `
<div class="mcp-preset-header">
<h4>${escapeHtml(preset.name)}</h4>
<span class="mcp-preset-status mcp-status-${statusClass}">${statusText}</span>
</div>
<p class="mcp-preset-description">${escapeHtml(preset.description)}</p>
<div class="mcp-preset-meta">
<span class="mcp-preset-app">App: ${escapeHtml(preset.app)}</span>
<span class="mcp-preset-events">${preset.events.length} events</span>
</div>
<div class="mcp-preset-actions">
<button class="mcp-preset-toggle ${buttonClass}" data-preset-id="${presetId}">
${buttonText}
</button>
</div>
`
// Attach event listener to toggle button
const toggleBtn = card.querySelector('.mcp-preset-toggle')
toggleBtn.addEventListener('click', () => togglePreset(presetId, preset.enabled))
return card
}
/**
* Toggle a webhook preset (enable/disable).
*
* @param {string} presetId Preset ID
* @param {boolean} currentlyEnabled Current enabled state
*/
async function togglePreset(presetId, currentlyEnabled) {
const card = document.querySelector(`[data-preset-id="${presetId}"]`)
if (!card) return
const toggleBtn = card.querySelector('.mcp-preset-toggle')
const originalText = toggleBtn.textContent
// Disable button during request
toggleBtn.disabled = true
toggleBtn.textContent = currentlyEnabled ? 'Disabling...' : 'Enabling...'
try {
const action = currentlyEnabled ? 'disable' : 'enable'
const url = generateUrl(`/apps/astrolabe/api/admin/webhooks/presets/${presetId}/${action}`)
const response = await axios.post(url)
if (!response.data.success) {
throw new Error(response.data.error || `Failed to ${action} preset`)
}
// Reload presets to update UI
await initWebhookManagement()
// Show success notification
OC.Notification.showTemporary(response.data.message || `Preset ${action}d successfully`)
} catch (error) {
console.error(`Failed to toggle preset ${presetId}:`, error)
// Restore button state
toggleBtn.disabled = false
toggleBtn.textContent = originalText
// Show error notification
OC.Notification.showTemporary(
error.message || 'Failed to toggle webhook preset',
{ type: 'error' },
)
}
}
/**
* Escape HTML to prevent XSS.
*
* @param {string} text Text to escape
* @return {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
app.mount('#astrolabe-admin-settings')
+38 -51
View File
@@ -3,65 +3,52 @@
<div class="markdown-viewer" v-html="html" />
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
export default {
name: 'MarkdownViewer',
props: {
content: {
type: String,
required: true,
},
const props = defineProps({
content: {
type: String,
required: true,
},
})
data() {
const md = new MarkdownIt({
html: false, // Disable HTML for security
linkify: true,
breaks: true,
typographer: true,
})
const html = ref('')
return {
html: '',
md,
}
},
// Initialize markdown renderer
const md = new MarkdownIt({
html: false, // Disable HTML for security
linkify: true,
breaks: true,
typographer: true,
})
watch: {
content: {
immediate: true,
handler(newContent) {
this.renderMarkdown(newContent)
},
},
},
function renderMarkdown(text) {
if (!text) {
html.value = ''
return
}
methods: {
renderMarkdown(text) {
if (!text) {
this.html = ''
return
}
try {
this.html = this.md.render(text)
} catch (error) {
console.error('Markdown rendering error:', error)
// Fallback to escaped plain text
this.html = `<pre>${this.escapeHtml(text)}</pre>`
}
},
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
},
},
try {
html.value = md.render(text)
} catch (error) {
console.error('Markdown rendering error:', error)
// Fallback to escaped plain text
html.value = `<pre>${escapeHtml(text)}</pre>`
}
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Watch for content changes
watch(() => props.content, (newContent) => {
renderMarkdown(newContent)
}, { immediate: true })
</script>
<style scoped lang="scss">
+146 -145
View File
@@ -8,165 +8,166 @@
<AlertCircle :size="48" />
<p>{{ error }}</p>
</div>
<div v-else ref="container" class="pdf-canvas-container">
<canvas ref="canvas" />
<div v-else ref="containerRef" class="pdf-canvas-container">
<canvas ref="canvasRef" />
</div>
</div>
</template>
<script>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { NcLoadingIcon } from '@nextcloud/vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
export default {
name: 'PDFViewer',
components: {
NcLoadingIcon,
AlertCircle,
const props = defineProps({
filePath: {
type: String,
required: true,
},
props: {
filePath: {
type: String,
required: true,
},
pageNumber: {
type: Number,
default: 1,
},
scale: {
type: Number,
default: 1.5,
},
pageNumber: {
type: Number,
default: 1,
},
data() {
return {
pdfDoc: null,
loading: true,
error: null,
totalPages: 0,
scale: {
type: Number,
default: 1.5,
},
})
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
// Reactive state
const pdfDoc = ref(null)
const loading = ref(true)
const error = ref(null)
const totalPages = ref(0)
const canvasRef = ref(null)
const containerRef = ref(null)
// Methods
async function loadPDF() {
loading.value = true
error.value = null
try {
// Clean and encode the file path
const cleanPath = props.filePath.startsWith('/')
? props.filePath.substring(1)
: props.filePath
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
// Load PDF document
const loadingTask = pdfjsLib.getDocument({
url: downloadUrl,
withCredentials: true,
useWorkerFetch: false, // Disable worker fetch for CSP compliance
isEvalSupported: false, // Disable eval for CSP
})
pdfDoc.value = await loadingTask.promise
totalPages.value = pdfDoc.value.numPages
emit('loaded', { totalPages: totalPages.value })
// Set loading to false - the watcher will handle rendering
loading.value = false
} catch (err) {
console.error('PDF load error:', err)
// Provide user-friendly error messages
if (err.name === 'MissingPDFException') {
error.value = t('astrolabe', 'PDF file not found')
} else if (err.name === 'InvalidPDFException') {
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
error.value = t('astrolabe', 'Network error loading PDF')
} else if (err.message?.includes('404')) {
error.value = t('astrolabe', 'PDF file not found')
} else {
error.value = t('astrolabe', 'Unable to load PDF file')
}
},
watch: {
pageNumber(newPage) {
if (this.pdfDoc && newPage > 0 && newPage <= this.totalPages) {
this.renderPage(newPage)
}
},
filePath() {
// Reload PDF if file path changes
this.loadPDF()
},
async loading(newLoading) {
// When loading completes, wait for canvas to be available and render
if (!newLoading && this.pdfDoc && !this.error) {
// Wait for Vue to update DOM
await this.$nextTick()
// Canvas should now be rendered (v-else condition)
if (this.$refs.canvas) {
await this.renderPage(this.pageNumber)
}
}
},
},
async mounted() {
await this.loadPDF()
},
beforeUnmount() {
if (this.pdfDoc) {
this.pdfDoc.destroy()
}
},
methods: {
t,
async loadPDF() {
this.loading = true
this.error = null
try {
// Clean and encode the file path
const cleanPath = this.filePath.startsWith('/')
? this.filePath.substring(1)
: this.filePath
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
// Load PDF document
const loadingTask = pdfjsLib.getDocument({
url: downloadUrl,
withCredentials: true,
useWorkerFetch: false, // Disable worker fetch for CSP compliance
isEvalSupported: false, // Disable eval for CSP
})
this.pdfDoc = await loadingTask.promise
this.totalPages = this.pdfDoc.numPages
this.$emit('loaded', { totalPages: this.totalPages })
// Set loading to false - the watcher will handle rendering
this.loading = false
} catch (err) {
console.error('PDF load error:', err)
// Provide user-friendly error messages
if (err.name === 'MissingPDFException') {
this.error = t('astrolabe', 'PDF file not found')
} else if (err.name === 'InvalidPDFException') {
this.error = t('astrolabe', 'Invalid or corrupted PDF file')
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
this.error = t('astrolabe', 'Network error loading PDF')
} else if (err.message?.includes('404')) {
this.error = t('astrolabe', 'PDF file not found')
} else {
this.error = t('astrolabe', 'Unable to load PDF file')
}
this.$emit('error', err)
this.loading = false
}
},
async renderPage(pageNum) {
if (!this.pdfDoc) {
return
}
try {
const page = await this.pdfDoc.getPage(pageNum)
const canvas = this.$refs.canvas
if (!canvas) {
console.error('PDF canvas ref not found')
this.error = t('astrolabe', 'Canvas element not available')
return
}
const context = canvas.getContext('2d')
// Use scale for better resolution on high-DPI screens
const viewport = page.getViewport({ scale: this.scale })
canvas.height = viewport.height
canvas.width = viewport.width
// Render page to canvas
const renderContext = {
canvasContext: context,
viewport,
}
await page.render(renderContext).promise
this.$emit('page-rendered', { pageNumber: pageNum })
} catch (err) {
console.error('PDF render error:', err)
this.error = t('astrolabe', 'Error rendering PDF page')
this.$emit('error', err)
}
},
},
emit('error', err)
loading.value = false
}
}
async function renderPage(pageNum) {
if (!pdfDoc.value) {
return
}
try {
const page = await pdfDoc.value.getPage(pageNum)
const canvas = canvasRef.value
if (!canvas) {
console.error('PDF canvas ref not found')
error.value = t('astrolabe', 'Canvas element not available')
return
}
const context = canvas.getContext('2d')
// Use scale for better resolution on high-DPI screens
const viewport = page.getViewport({ scale: props.scale })
canvas.height = viewport.height
canvas.width = viewport.width
// Render page to canvas
const renderContext = {
canvasContext: context,
viewport,
}
await page.render(renderContext).promise
emit('page-rendered', { pageNumber: pageNum })
} catch (err) {
console.error('PDF render error:', err)
error.value = t('astrolabe', 'Error rendering PDF page')
emit('error', err)
}
}
// Watchers
watch(() => props.pageNumber, (newPage) => {
if (pdfDoc.value && newPage > 0 && newPage <= totalPages.value) {
renderPage(newPage)
}
})
watch(() => props.filePath, () => {
// Reload PDF if file path changes
loadPDF()
})
watch(loading, async (newLoading) => {
// When loading completes, wait for canvas to be available and render
if (!newLoading && pdfDoc.value && !error.value) {
// Wait for Vue to update DOM
await nextTick()
// Canvas should now be rendered (v-else condition)
if (canvasRef.value) {
await renderPage(props.pageNumber)
}
}
})
// Lifecycle hooks
onMounted(() => {
loadPDF()
})
onBeforeUnmount(() => {
if (pdfDoc.value) {
pdfDoc.value.destroy()
}
})
</script>
<style scoped lang="scss">
@@ -0,0 +1,672 @@
<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>
</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 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>
+4 -295
View File
@@ -2,305 +2,14 @@
/**
* Admin settings template for Astrolabe.
*
* Displays semantic search service status, indexing metrics, configuration,
* and provides administrative controls.
*
* @var array $_ Template parameters
* @var array $_['serverStatus'] Server status from API
* @var array $_['vectorSyncStatus'] Vector sync metrics from API
* @var string $_['serverUrl'] Configured Astrolabe service URL
* @var bool $_['apiKeyConfigured'] Whether API key is set in config.php
* @var bool $_['vectorSyncEnabled'] Whether vector sync is enabled
* Mounts the Vue.js admin settings component for async loading
* and improved UX.
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-adminSettings');
?>
<div id="mcp-admin-settings" class="section">
<h2><?php p($l->t('Astrolabe Administration')); ?></h2>
<div class="mcp-settings-info">
<p><?php p($l->t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?></p>
<p><?php p($l->t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?></p>
</div>
<!-- Service Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Service Status')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Version')); ?></strong></td>
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Uptime')); ?></strong></td>
<td>
<?php if (isset($_['serverStatus']['uptime_seconds'])): ?>
<?php
$uptime = $_['serverStatus']['uptime_seconds'];
$hours = floor($uptime / 3600);
$minutes = floor(($uptime % 3600) / 60);
p(sprintf('%d hours, %d minutes', $hours, $minutes));
?>
<?php else: ?>
<?php p($l->t('Unknown')); ?>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('Semantic Search')); ?></strong></td>
<td>
<?php if ($_['vectorSyncEnabled']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Enabled')); ?>
</span>
<?php else: ?>
<span class="badge badge-neutral">
<?php p($l->t('Disabled')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
</div>
<!-- Indexing Metrics -->
<?php if ($_['vectorSyncEnabled'] && !isset($_['vectorSyncStatus']['error'])): ?>
<div class="mcp-status-card" id="vector-sync-metrics">
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Status')); ?></strong></td>
<td>
<?php
$status = $_['vectorSyncStatus']['status'] ?? 'unknown';
$statusClass = $status === 'idle' ? 'success' : ($status === 'syncing' ? 'info' : 'neutral');
?>
<span class="badge badge-<?php p($statusClass); ?>">
<?php p(ucfirst($status)); ?>
</span>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('Indexed Documents')); ?></strong></td>
<td><?php p(number_format($_['vectorSyncStatus']['indexed_documents'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Pending Documents')); ?></strong></td>
<td><?php p(number_format($_['vectorSyncStatus']['pending_documents'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Last Sync')); ?></strong></td>
<td><?php p($_['vectorSyncStatus']['last_sync_time'] ?? 'Never'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Processing Rate')); ?></strong></td>
<td><?php p(sprintf('%.1f docs/sec', $_['vectorSyncStatus']['documents_per_second'] ?? 0)); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Errors (24h)')); ?></strong></td>
<td>
<?php
$errors = $_['vectorSyncStatus']['errors_24h'] ?? 0;
if ($errors > 0): ?>
<span class="error"><?php p($errors); ?></span>
<?php else: ?>
<?php p('0'); ?>
<?php endif; ?>
</td>
</tr>
</table>
<p class="mcp-help-text">
<?php p($l->t('Metrics are updated in real-time. Refresh the page to see latest values.')); ?>
</p>
</div>
<?php elseif ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card mcp-error">
<h3><?php p($l->t('Indexing Metrics')); ?></h3>
<div class="notecard notecard-error">
<p><?php p($l->t('Failed to retrieve indexing status:')); ?></p>
<p><code><?php p($_['vectorSyncStatus']['error'] ?? 'Unknown error'); ?></code></p>
</div>
</div>
<?php endif; ?>
<!-- Search Settings -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card" id="search-settings">
<h3><?php p($l->t('AI Search Provider Settings')); ?></h3>
<p class="mcp-settings-description">
<?php p($l->t('Configure the default search parameters for the AI Search provider in Nextcloud unified search.')); ?>
</p>
<form id="astrolabe-search-settings-form" class="mcp-settings-form">
<div class="mcp-form-group">
<label for="search-algorithm"><?php p($l->t('Search Algorithm')); ?></label>
<select id="search-algorithm" name="algorithm" class="mcp-select">
<option value="hybrid" <?php if ($_['searchSettings']['algorithm'] === 'hybrid') {
echo 'selected';
} ?>>
<?php p($l->t('Hybrid (Recommended)')); ?>
</option>
<option value="semantic" <?php if ($_['searchSettings']['algorithm'] === 'semantic') {
echo 'selected';
} ?>>
<?php p($l->t('Semantic Only')); ?>
</option>
<option value="bm25" <?php if ($_['searchSettings']['algorithm'] === 'bm25') {
echo 'selected';
} ?>>
<?php p($l->t('Keyword (BM25) Only')); ?>
</option>
</select>
<p class="mcp-help-text">
<?php p($l->t('Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.')); ?>
</p>
</div>
<div class="mcp-form-group">
<label for="search-fusion"><?php p($l->t('Fusion Method')); ?></label>
<select id="search-fusion" name="fusion" class="mcp-select">
<option value="rrf" <?php if ($_['searchSettings']['fusion'] === 'rrf') {
echo 'selected';
} ?>>
<?php p($l->t('RRF - Reciprocal Rank Fusion (Recommended)')); ?>
</option>
<option value="dbsf" <?php if ($_['searchSettings']['fusion'] === 'dbsf') {
echo 'selected';
} ?>>
<?php p($l->t('DBSF - Distribution-Based Score Fusion')); ?>
</option>
</select>
<p class="mcp-help-text">
<?php p($l->t('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>
<div class="mcp-form-group">
<label for="search-score-threshold">
<?php p($l->t('Minimum Score Threshold')); ?>:
<span id="score-threshold-value"><?php p($_['searchSettings']['scoreThreshold']); ?>%</span>
</label>
<input type="range"
id="search-score-threshold"
name="scoreThreshold"
min="0"
max="100"
step="5"
value="<?php p($_['searchSettings']['scoreThreshold']); ?>"
class="mcp-range" />
<p class="mcp-help-text">
<?php p($l->t('Filter out results below this relevance score. Set to 0 to show all results.')); ?>
</p>
</div>
<div class="mcp-form-group">
<label for="search-limit"><?php p($l->t('Maximum Results')); ?></label>
<input type="number"
id="search-limit"
name="limit"
min="5"
max="100"
step="5"
value="<?php p($_['searchSettings']['limit']); ?>"
class="mcp-input" />
<p class="mcp-help-text">
<?php p($l->t('Maximum number of results to return per search query (5-100).')); ?>
</p>
</div>
<div class="mcp-form-actions">
<button type="submit" class="primary">
<?php p($l->t('Save Settings')); ?>
</button>
<span id="search-settings-status" class="mcp-status-message"></span>
</div>
</form>
</div>
<?php endif; ?>
<!-- Webhook Management -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card" id="webhook-presets">
<h3><?php p($l->t('Webhook Management')); ?></h3>
<p class="mcp-settings-description">
<?php p($l->t('Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.')); ?>
</p>
<div id="webhook-presets-container">
<div class="mcp-loading">
<?php p($l->t('Loading webhook presets...')); ?>
</div>
</div>
<div class="notecard notecard-info">
<p><strong><?php p($l->t('How Webhooks Work')); ?></strong></p>
<ul>
<li><?php p($l->t('Enable a preset to register webhooks for that app with the MCP server')); ?></li>
<li><?php p($l->t('When content changes in Nextcloud, webhooks notify the MCP server instantly')); ?></li>
<li><?php p($l->t('The MCP server updates its vector index in real-time for semantic search')); ?></li>
<li><?php p($l->t('Disable a preset to stop receiving updates for that app')); ?></li>
</ul>
</div>
<div class="notecard notecard-warning">
<p><strong><?php p($l->t('Requirements')); ?></strong></p>
<ul>
<li><?php p($l->t('The webhook_listeners app must be installed and enabled in Nextcloud')); ?></li>
<li><?php p($l->t('The MCP server must be reachable from your Nextcloud instance')); ?></li>
<li><?php p($l->t('You must have authorized Astrolabe with the MCP server (see Personal Settings)')); ?></li>
</ul>
</div>
</div>
<?php endif; ?>
<!-- Capabilities -->
<div class="mcp-status-card">
<h3><?php p($l->t('Capabilities')); ?></h3>
<ul class="mcp-feature-list">
<li>
<span class="icon icon-search"></span>
<strong><?php p($l->t('Semantic Search')); ?></strong>
<p><?php p($l->t('Search by meaning across Notes, Files, Calendar, and Deck using natural language queries.')); ?></p>
</li>
<?php if ($_['vectorSyncEnabled']): ?>
<li>
<span class="icon icon-category-monitoring"></span>
<strong><?php p($l->t('Vector Visualization')); ?></strong>
<p><?php p($l->t('Explore content relationships in an interactive 2D visualization.')); ?></p>
</li>
<?php endif; ?>
<li>
<span class="icon icon-user"></span>
<strong><?php p($l->t('Per-User Indexing')); ?></strong>
<p><?php p($l->t('Users control their own content indexing via Personal Settings.')); ?></p>
</li>
<li>
<span class="icon icon-toggle"></span>
<strong><?php p($l->t('Hybrid Search')); ?></strong>
<p><?php p($l->t('Combines semantic understanding with keyword matching for optimal results.')); ?></p>
</li>
</ul>
</div>
<!-- Documentation -->
<div class="mcp-status-card">
<h3><?php p($l->t('Documentation')); ?></h3>
<ul class="mcp-links">
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
<?php p($l->t('Configuration Guide')); ?>
</a>
</li>
<li>
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
<?php p($l->t('GitHub Repository')); ?>
</a>
</li>
</ul>
</div>
<div id="astrolabe-admin-settings" class="section">
<!-- Vue component will be mounted here -->
</div>
+10 -4
View File
@@ -5,7 +5,7 @@ import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
outDir: 'js',
outDir: '.',
emptyOutDir: false,
rollupOptions: {
input: {
@@ -14,9 +14,15 @@ export default defineConfig({
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
},
output: {
entryFileNames: '[name].mjs',
chunkFileNames: '[name]-[hash].chunk.mjs',
assetFileNames: '[name][extname]',
entryFileNames: 'js/[name].mjs',
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
assetFileNames: (assetInfo) => {
// Output CSS to css/ directory, JS/other assets to js/
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return 'css/[name][extname]';
}
return 'js/[name][extname]';
},
},
},
sourcemap: true,