feat: Migrate to vue 3
This commit is contained in:
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
|||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
The server supports two authentication modes:
|
The server supports three authentication modes:
|
||||||
|
|
||||||
**Single-User Mode (BasicAuth):**
|
**Single-User Mode (BasicAuth):**
|
||||||
- One set of credentials shared by all MCP clients
|
- 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
|
- More secure: tokens expire, credentials never shared with server
|
||||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
- 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.
|
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||||
|
|
||||||
## Semantic Search
|
## Semantic Search
|
||||||
|
|||||||
@@ -140,6 +140,93 @@ Basic Authentication uses username and password credentials directly.
|
|||||||
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
||||||
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
- [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
|
## Mode Detection
|
||||||
|
|
||||||
The server automatically detects the authentication mode:
|
The server automatically detects the authentication mode:
|
||||||
|
|||||||
+250
-12
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
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.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
|
Settings,
|
||||||
get_document_processor_config,
|
get_document_processor_config,
|
||||||
get_settings,
|
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):
|
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
|
||||||
# Initialize observability (logging will be configured by uvicorn)
|
# Initialize observability (logging will be configured by uvicorn)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -1043,6 +1200,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
else DeploymentMode.SELF_HOSTED
|
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)
|
# Setup Prometheus metrics (always enabled by default)
|
||||||
if settings.metrics_enabled:
|
if settings.metrics_enabled:
|
||||||
setup_metrics(port=settings.metrics_port)
|
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)
|
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
|
||||||
# to avoid async context issues
|
# to avoid async context issues
|
||||||
multi_user_basic_oauth_creds: tuple[str, str] | None = None
|
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 (
|
if (
|
||||||
mode == AuthMode.MULTI_USER_BASIC
|
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
|
# Run DCR synchronously before uvicorn starts
|
||||||
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
|
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
|
# Create MCP server based on detected mode
|
||||||
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
|
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
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
|
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
|
||||||
# This allows Astrolabe to use management APIs with OAuth bearer tokens
|
# This allows Astrolabe to use management APIs with OAuth bearer tokens
|
||||||
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
|
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
|
||||||
# Check if we have OAuth credentials from DCR
|
# Check if we have OAuth credentials AND infrastructure from setup
|
||||||
if multi_user_basic_oauth_creds:
|
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
|
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
|
nextcloud_host_for_context = settings.nextcloud_host
|
||||||
mcp_server_url = os.getenv(
|
mcp_server_url = os.getenv(
|
||||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
"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 = {
|
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
|
"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": {
|
"config": {
|
||||||
"mcp_server_url": mcp_server_url,
|
"mcp_server_url": mcp_server_url,
|
||||||
"discovery_url": discovery_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
|
app.state.oauth_context = oauth_context_dict
|
||||||
logger.info(
|
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
|
# 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.
|
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
# 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)
|
# Import OAuth routes (ADR-004 Progressive Consent)
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
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)"
|
"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
|
# Add unified OAuth callback endpoint supporting both flows
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||||
oauth_authorize_nextcloud,
|
oauth_authorize_nextcloud,
|
||||||
@@ -2124,9 +2354,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
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)
|
# Add browser OAuth login routes (OAuth mode only)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||||
|
|||||||
+10
@@ -74,6 +74,16 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Admin settings routes
|
// 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',
|
'name' => 'api#saveSearchSettings',
|
||||||
'url' => '/api/admin/search-settings',
|
'url' => '/api/admin/search-settings',
|
||||||
|
|||||||
@@ -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.
|
* Save admin search settings.
|
||||||
*
|
*
|
||||||
|
|||||||
+8
-35
@@ -47,11 +47,7 @@ class Admin implements ISettings {
|
|||||||
* @return TemplateResponse
|
* @return TemplateResponse
|
||||||
*/
|
*/
|
||||||
public function getForm(): TemplateResponse {
|
public function getForm(): TemplateResponse {
|
||||||
// Fetch data from MCP server
|
// Get configuration from config.php (local, fast)
|
||||||
$serverStatus = $this->client->getStatus();
|
|
||||||
$vectorSyncStatus = $this->client->getVectorSyncStatus();
|
|
||||||
|
|
||||||
// Get configuration from config.php
|
|
||||||
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||||
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
||||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||||
@@ -59,21 +55,6 @@ class Admin implements ISettings {
|
|||||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||||
$clientSecretConfigured = !empty($clientSecret);
|
$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
|
// Load search settings from app config
|
||||||
$searchSettings = [
|
$searchSettings = [
|
||||||
'algorithm' => $this->config->getAppValue(
|
'algorithm' => $this->config->getAppValue(
|
||||||
@@ -98,27 +79,19 @@ class Admin implements ISettings {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Provide initial state for Vue.js frontend (if needed)
|
// Provide initial state for Vue.js frontend
|
||||||
$this->initialState->provideInitialState('server-data', [
|
// MCP server data will be fetched asynchronously by Vue component
|
||||||
'serverStatus' => $serverStatus,
|
$this->initialState->provideInitialState('admin-config', [
|
||||||
'vectorSyncStatus' => $vectorSyncStatus,
|
|
||||||
'config' => [
|
'config' => [
|
||||||
'serverUrl' => $serverUrl,
|
'serverUrl' => $serverUrl,
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
'apiKeyConfigured' => $apiKeyConfigured,
|
||||||
],
|
|
||||||
'searchSettings' => $searchSettings,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$parameters = [
|
|
||||||
'serverStatus' => $serverStatus,
|
|
||||||
'vectorSyncStatus' => $vectorSyncStatus,
|
|
||||||
'serverUrl' => $serverUrl,
|
|
||||||
'apiKeyConfigured' => $apiKeyConfigured,
|
|
||||||
'clientIdConfigured' => $clientIdConfigured,
|
'clientIdConfigured' => $clientIdConfigured,
|
||||||
'clientSecretConfigured' => $clientSecretConfigured,
|
'clientSecretConfigured' => $clientSecretConfigured,
|
||||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
],
|
||||||
'searchSettings' => $searchSettings,
|
'searchSettings' => $searchSettings,
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
$parameters = [];
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
Application::APP_ID,
|
Application::APP_ID,
|
||||||
|
|||||||
+581
-1991
File diff suppressed because it is too large
Load Diff
Vendored
+3
-1
@@ -19,6 +19,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
|
"@nextcloud/dialogs": "^7.2.0",
|
||||||
|
"@nextcloud/initial-state": "^3.0.0",
|
||||||
"@nextcloud/l10n": "^3.1.0",
|
"@nextcloud/l10n": "^3.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.0.0",
|
"@nextcloud/vue": "^9.0.0",
|
||||||
@@ -32,8 +34,8 @@
|
|||||||
"@nextcloud/browserslist-config": "3.1.2",
|
"@nextcloud/browserslist-config": "3.1.2",
|
||||||
"@nextcloud/eslint-config": "8.4.2",
|
"@nextcloud/eslint-config": "8.4.2",
|
||||||
"@nextcloud/stylelint-config": "3.1.1",
|
"@nextcloud/stylelint-config": "3.1.1",
|
||||||
"@nextcloud/vite-config": "1.7.2",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"sass-embedded": "^1.97.1",
|
||||||
"terser": "5.44.1",
|
"terser": "5.44.1",
|
||||||
"vite": "7.2.7"
|
"vite": "7.2.7"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
-5
@@ -48,10 +48,11 @@
|
|||||||
<div class="mcp-search-card">
|
<div class="mcp-search-card">
|
||||||
<div class="mcp-search-row">
|
<div class="mcp-search-row">
|
||||||
<NcTextField
|
<NcTextField
|
||||||
v-model:value="query"
|
:value="query"
|
||||||
:label="t('astrolabe', 'Search query')"
|
:label="t('astrolabe', 'Search query')"
|
||||||
:placeholder="t('astrolabe', 'Enter your search query...')"
|
:placeholder="t('astrolabe', 'Enter your search query...')"
|
||||||
class="mcp-search-input"
|
class="mcp-search-input"
|
||||||
|
@update:value="query = $event"
|
||||||
@keyup.enter="performSearch" />
|
@keyup.enter="performSearch" />
|
||||||
|
|
||||||
<NcSelect
|
<NcSelect
|
||||||
@@ -104,10 +105,11 @@
|
|||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
||||||
<NcTextField
|
<NcTextField
|
||||||
v-model:value="limit"
|
:value="limit"
|
||||||
type="number"
|
type="number"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="100" />
|
:max="100"
|
||||||
|
@update:value="limit = Number($event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mcp-option-group">
|
<div class="mcp-option-group">
|
||||||
@@ -152,9 +154,9 @@
|
|||||||
<div class="mcp-viz-header">
|
<div class="mcp-viz-header">
|
||||||
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
||||||
<NcCheckboxRadioSwitch
|
<NcCheckboxRadioSwitch
|
||||||
v-model:checked="showQueryPoint"
|
:checked="showQueryPoint"
|
||||||
type="switch"
|
type="switch"
|
||||||
@update:checked="updatePlot">
|
@update:checked="showQueryPoint = $event; updatePlot()">
|
||||||
{{ t('astrolabe', 'Show query point') }}
|
{{ t('astrolabe', 'Show query point') }}
|
||||||
</NcCheckboxRadioSwitch>
|
</NcCheckboxRadioSwitch>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-238
@@ -1,245 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Admin settings page JavaScript for Astrolabe.
|
* Admin settings page Vue app for Astrolabe.
|
||||||
*
|
*
|
||||||
* Handles:
|
* Mounts the AdminSettings Vue component for async loading
|
||||||
* - Loading webhook presets
|
* and improved UX.
|
||||||
* - Enabling/disabling webhook presets
|
|
||||||
* - Search settings form submission
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { createApp } from 'vue'
|
||||||
import axios from '@nextcloud/axios'
|
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||||
import './styles/settings.css'
|
import AdminSettings from './components/admin/AdminSettings.vue'
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
const app = createApp(AdminSettings)
|
||||||
// Initialize search settings form
|
|
||||||
initSearchSettingsForm()
|
|
||||||
|
|
||||||
// Initialize webhook management (only if webhook section exists)
|
// Add translation methods globally
|
||||||
if (document.getElementById('webhook-presets')) {
|
app.config.globalProperties.t = t
|
||||||
initWebhookManagement()
|
app.config.globalProperties.n = n
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
app.mount('#astrolabe-admin-settings')
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-31
@@ -3,20 +3,20 @@
|
|||||||
<div class="markdown-viewer" v-html="html" />
|
<div class="markdown-viewer" v-html="html" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'MarkdownViewer',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
|
|
||||||
data() {
|
const html = ref('')
|
||||||
|
|
||||||
|
// Initialize markdown renderer
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false, // Disable HTML for security
|
html: false, // Disable HTML for security
|
||||||
linkify: true,
|
linkify: true,
|
||||||
@@ -24,44 +24,31 @@ export default {
|
|||||||
typographer: true,
|
typographer: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
function renderMarkdown(text) {
|
||||||
html: '',
|
|
||||||
md,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
content: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newContent) {
|
|
||||||
this.renderMarkdown(newContent)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
renderMarkdown(text) {
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
this.html = ''
|
html.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.html = this.md.render(text)
|
html.value = md.render(text)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Markdown rendering error:', error)
|
console.error('Markdown rendering error:', error)
|
||||||
// Fallback to escaped plain text
|
// Fallback to escaped plain text
|
||||||
this.html = `<pre>${this.escapeHtml(text)}</pre>`
|
html.value = `<pre>${escapeHtml(text)}</pre>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
div.textContent = text
|
div.textContent = text
|
||||||
return div.innerHTML
|
return div.innerHTML
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch for content changes
|
||||||
|
watch(() => props.content, (newContent) => {
|
||||||
|
renderMarkdown(newContent)
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
+82
-81
@@ -8,26 +8,21 @@
|
|||||||
<AlertCircle :size="48" />
|
<AlertCircle :size="48" />
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else ref="container" class="pdf-canvas-container">
|
<div v-else ref="containerRef" class="pdf-canvas-container">
|
||||||
<canvas ref="canvas" />
|
<canvas ref="canvasRef" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import * as pdfjsLib from 'pdfjs-dist'
|
import * as pdfjsLib from 'pdfjs-dist'
|
||||||
import { generateUrl } from '@nextcloud/router'
|
import { generateUrl } from '@nextcloud/router'
|
||||||
import { translate as t } from '@nextcloud/l10n'
|
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'
|
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'PDFViewer',
|
|
||||||
components: {
|
|
||||||
NcLoadingIcon,
|
|
||||||
AlertCircle,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
filePath: {
|
filePath: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -40,56 +35,28 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 1.5,
|
default: 1.5,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
data() {
|
|
||||||
return {
|
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||||
pdfDoc: null,
|
|
||||||
loading: true,
|
// Reactive state
|
||||||
error: null,
|
const pdfDoc = ref(null)
|
||||||
totalPages: 0,
|
const loading = ref(true)
|
||||||
}
|
const error = ref(null)
|
||||||
},
|
const totalPages = ref(0)
|
||||||
watch: {
|
const canvasRef = ref(null)
|
||||||
pageNumber(newPage) {
|
const containerRef = ref(null)
|
||||||
if (this.pdfDoc && newPage > 0 && newPage <= this.totalPages) {
|
|
||||||
this.renderPage(newPage)
|
// Methods
|
||||||
}
|
async function loadPDF() {
|
||||||
},
|
loading.value = true
|
||||||
filePath() {
|
error.value = null
|
||||||
// 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 {
|
try {
|
||||||
// Clean and encode the file path
|
// Clean and encode the file path
|
||||||
const cleanPath = this.filePath.startsWith('/')
|
const cleanPath = props.filePath.startsWith('/')
|
||||||
? this.filePath.substring(1)
|
? props.filePath.substring(1)
|
||||||
: this.filePath
|
: props.filePath
|
||||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
||||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
||||||
|
|
||||||
@@ -101,51 +68,52 @@ export default {
|
|||||||
isEvalSupported: false, // Disable eval for CSP
|
isEvalSupported: false, // Disable eval for CSP
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pdfDoc = await loadingTask.promise
|
pdfDoc.value = await loadingTask.promise
|
||||||
this.totalPages = this.pdfDoc.numPages
|
totalPages.value = pdfDoc.value.numPages
|
||||||
this.$emit('loaded', { totalPages: this.totalPages })
|
emit('loaded', { totalPages: totalPages.value })
|
||||||
|
|
||||||
// Set loading to false - the watcher will handle rendering
|
// Set loading to false - the watcher will handle rendering
|
||||||
this.loading = false
|
loading.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF load error:', err)
|
console.error('PDF load error:', err)
|
||||||
|
|
||||||
// Provide user-friendly error messages
|
// Provide user-friendly error messages
|
||||||
if (err.name === 'MissingPDFException') {
|
if (err.name === 'MissingPDFException') {
|
||||||
this.error = t('astrolabe', 'PDF file not found')
|
error.value = t('astrolabe', 'PDF file not found')
|
||||||
} else if (err.name === 'InvalidPDFException') {
|
} else if (err.name === 'InvalidPDFException') {
|
||||||
this.error = t('astrolabe', 'Invalid or corrupted PDF file')
|
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
|
||||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
||||||
this.error = t('astrolabe', 'Network error loading PDF')
|
error.value = t('astrolabe', 'Network error loading PDF')
|
||||||
} else if (err.message?.includes('404')) {
|
} else if (err.message?.includes('404')) {
|
||||||
this.error = t('astrolabe', 'PDF file not found')
|
error.value = t('astrolabe', 'PDF file not found')
|
||||||
} else {
|
} else {
|
||||||
this.error = t('astrolabe', 'Unable to load PDF file')
|
error.value = t('astrolabe', 'Unable to load PDF file')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('error', err)
|
emit('error', err)
|
||||||
this.loading = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
async renderPage(pageNum) {
|
|
||||||
if (!this.pdfDoc) {
|
async function renderPage(pageNum) {
|
||||||
|
if (!pdfDoc.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await this.pdfDoc.getPage(pageNum)
|
const page = await pdfDoc.value.getPage(pageNum)
|
||||||
const canvas = this.$refs.canvas
|
const canvas = canvasRef.value
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.error('PDF canvas ref not found')
|
console.error('PDF canvas ref not found')
|
||||||
this.error = t('astrolabe', 'Canvas element not available')
|
error.value = t('astrolabe', 'Canvas element not available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
// Use scale for better resolution on high-DPI screens
|
// Use scale for better resolution on high-DPI screens
|
||||||
const viewport = page.getViewport({ scale: this.scale })
|
const viewport = page.getViewport({ scale: props.scale })
|
||||||
|
|
||||||
canvas.height = viewport.height
|
canvas.height = viewport.height
|
||||||
canvas.width = viewport.width
|
canvas.width = viewport.width
|
||||||
@@ -158,15 +126,48 @@ export default {
|
|||||||
|
|
||||||
await page.render(renderContext).promise
|
await page.render(renderContext).promise
|
||||||
|
|
||||||
this.$emit('page-rendered', { pageNumber: pageNum })
|
emit('page-rendered', { pageNumber: pageNum })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF render error:', err)
|
console.error('PDF render error:', err)
|
||||||
this.error = t('astrolabe', 'Error rendering PDF page')
|
error.value = t('astrolabe', 'Error rendering PDF page')
|
||||||
this.$emit('error', err)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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
@@ -2,305 +2,14 @@
|
|||||||
/**
|
/**
|
||||||
* Admin settings template for Astrolabe.
|
* Admin settings template for Astrolabe.
|
||||||
*
|
*
|
||||||
* Displays semantic search service status, indexing metrics, configuration,
|
* Mounts the Vue.js admin settings component for async loading
|
||||||
* and provides administrative controls.
|
* and improved UX.
|
||||||
*
|
|
||||||
* @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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
script('astrolabe', 'astrolabe-adminSettings');
|
script('astrolabe', 'astrolabe-adminSettings');
|
||||||
style('astrolabe', 'astrolabe-adminSettings');
|
style('astrolabe', 'astrolabe-adminSettings');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="mcp-admin-settings" class="section">
|
<div id="astrolabe-admin-settings" class="section">
|
||||||
<h2><?php p($l->t('Astrolabe Administration')); ?></h2>
|
<!-- Vue component will be mounted here -->
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
Vendored
+10
-4
@@ -5,7 +5,7 @@ import { resolve } from 'path'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'js',
|
outDir: '.',
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
@@ -14,9 +14,15 @@ export default defineConfig({
|
|||||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: '[name].mjs',
|
entryFileNames: 'js/[name].mjs',
|
||||||
chunkFileNames: '[name]-[hash].chunk.mjs',
|
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||||
assetFileNames: '[name][extname]',
|
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,
|
sourcemap: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user