feat: Phase 4 — dynamic nav + service visibility based on installed services
Unit Tests / test (push) Successful in 11m24s
Unit Tests / test (push) Successful in 11m24s
Email, calendar, and files no longer appear in the nav or as usable pages unless they are installed. The nav refreshes whenever a service is installed or removed via the new pic-services-changed CustomEvent. Changes: - routes/services.py: add GET /api/services/active endpoint - api.js: add servicesAPI.listActive() - App.jsx: replace hardcoded coreServiceChildren with dynamic state fetched from /api/services/active; SERVICE_META maps ids to nav entry shapes - ServiceNotInstalledBanner.jsx: new component — admin gets catalog link, peer gets "contact admin" message - EmailPage/CalendarPage/FilesPage: show banner when service not installed - ServicesIndex.jsx: remove CoreServiceCard + CORE_SERVICES "Built-in" section; rename Remove → Uninstall; dispatch pic-services-changed on install/uninstall success - MyServices.jsx: conditionally render service cards based on active list; placeholder card when absent; page-level notice when nothing is installed - tests/test_services_active_endpoint.py: 4 new endpoint tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,26 @@ def get_services_catalog():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/active', methods=['GET'])
|
||||
def get_active_services():
|
||||
"""Return minimal info for all installed services. Used by webui to build nav."""
|
||||
try:
|
||||
from app import service_registry
|
||||
active = service_registry.list_active()
|
||||
return jsonify([
|
||||
{
|
||||
'id': svc['id'],
|
||||
'name': svc.get('name', svc['id']),
|
||||
'subdomain': svc.get('subdomain'),
|
||||
'capabilities': svc.get('capabilities', {}),
|
||||
}
|
||||
for svc in active
|
||||
])
|
||||
except Exception as e:
|
||||
logger.error('get_active_services: %s', e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>', methods=['GET'])
|
||||
def get_service_catalog_entry(service_id: str):
|
||||
"""Return a single service manifest+config, or 404 if unknown."""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Tests for GET /api/services/active endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
||||
|
||||
from app import app
|
||||
|
||||
|
||||
def _make_registry(active_services):
|
||||
reg = MagicMock()
|
||||
reg.list_active = MagicMock(return_value=active_services)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_active_returns_200(client):
|
||||
import app as app_module
|
||||
with patch.object(app_module, 'service_registry', _make_registry([])):
|
||||
resp = client.get('/api/services/active')
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_active_returns_empty_list_when_nothing_installed(client):
|
||||
import app as app_module
|
||||
with patch.object(app_module, 'service_registry', _make_registry([])):
|
||||
resp = client.get('/api/services/active')
|
||||
data = json.loads(resp.data)
|
||||
assert data == []
|
||||
|
||||
|
||||
def test_active_returns_installed_services(client):
|
||||
email_svc = {
|
||||
'id': 'email',
|
||||
'name': 'Email',
|
||||
'subdomain': 'mail',
|
||||
'capabilities': {'has_accounts': True},
|
||||
'config': {},
|
||||
}
|
||||
import app as app_module
|
||||
with patch.object(app_module, 'service_registry', _make_registry([email_svc])):
|
||||
resp = client.get('/api/services/active')
|
||||
data = json.loads(resp.data)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]['id'] == 'email'
|
||||
assert data[0]['name'] == 'Email'
|
||||
|
||||
|
||||
def test_active_response_shape(client):
|
||||
"""Each entry must have id, name, subdomain, and capabilities keys."""
|
||||
email_svc = {
|
||||
'id': 'email',
|
||||
'name': 'Email',
|
||||
'subdomain': 'mail',
|
||||
'capabilities': {'has_accounts': True},
|
||||
'config': {},
|
||||
}
|
||||
import app as app_module
|
||||
with patch.object(app_module, 'service_registry', _make_registry([email_svc])):
|
||||
resp = client.get('/api/services/active')
|
||||
data = json.loads(resp.data)
|
||||
entry = data[0]
|
||||
assert 'id' in entry
|
||||
assert 'name' in entry
|
||||
assert 'subdomain' in entry
|
||||
assert 'capabilities' in entry
|
||||
+28
-8
@@ -20,7 +20,7 @@ import {
|
||||
AlertTriangle,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { healthAPI, cellAPI } from './services/api';
|
||||
import { healthAPI, cellAPI, servicesAPI } from './services/api';
|
||||
import { ConfigProvider } from './contexts/ConfigContext';
|
||||
import { DraftConfigProvider, useDraftConfig } from './contexts/DraftConfigContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
@@ -48,6 +48,12 @@ import Connectivity from './pages/Connectivity';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupGuard from './components/SetupGuard';
|
||||
|
||||
const SERVICE_META = {
|
||||
email: { name: 'Email', href: '/services/email', icon: Mail },
|
||||
calendar: { name: 'Calendar', href: '/services/calendar', icon: CalendarIcon },
|
||||
files: { name: 'File Storage', href: '/services/files', icon: FolderOpen },
|
||||
};
|
||||
|
||||
function PendingRestartBanner({ pending, onApply, onCancel }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
@@ -230,18 +236,32 @@ function AppCore() {
|
||||
window.dispatchEvent(new CustomEvent('pic-config-discarded'));
|
||||
}, []);
|
||||
|
||||
const coreServiceChildren = [
|
||||
{ name: 'Email', href: '/services/email', icon: Mail },
|
||||
{ name: 'Calendar', href: '/services/calendar', icon: CalendarIcon },
|
||||
{ name: 'Files', href: '/services/files', icon: FolderOpen },
|
||||
];
|
||||
const [activeServiceChildren, setActiveServiceChildren] = useState([]);
|
||||
|
||||
const fetchActiveServices = useCallback(async () => {
|
||||
try {
|
||||
const resp = await servicesAPI.listActive();
|
||||
const children = (resp.data || [])
|
||||
.filter(svc => SERVICE_META[svc.id])
|
||||
.map(svc => SERVICE_META[svc.id]);
|
||||
setActiveServiceChildren(children);
|
||||
} catch {
|
||||
// silent — empty nav children is safe
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveServices();
|
||||
window.addEventListener('pic-services-changed', fetchActiveServices);
|
||||
return () => window.removeEventListener('pic-services-changed', fetchActiveServices);
|
||||
}, [fetchActiveServices]);
|
||||
|
||||
const adminNavigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{ name: 'Peers', href: '/peers', icon: Users },
|
||||
{ name: 'Network Services', href: '/network', icon: Network },
|
||||
{ name: 'WireGuard', href: '/wireguard', icon: Shield },
|
||||
{ name: 'Services', href: '/services', icon: Package, children: coreServiceChildren },
|
||||
{ name: 'Services', href: '/services', icon: Package, children: activeServiceChildren },
|
||||
{ name: 'Routing', href: '/routing', icon: Wifi },
|
||||
{ name: 'Vault', href: '/vault', icon: Key },
|
||||
{ name: 'Containers', href: '/containers', icon: Package2 },
|
||||
@@ -255,7 +275,7 @@ function AppCore() {
|
||||
const peerNavigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{ name: 'My Services', href: '/my-services', icon: Wifi },
|
||||
{ name: 'Services', href: '/services', icon: Package, children: coreServiceChildren },
|
||||
{ name: 'Services', href: '/services', icon: Package, children: activeServiceChildren },
|
||||
{ name: 'Account', href: '/account', icon: User },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
export default function ServiceNotInstalledBanner({ isAdmin = false }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
|
||||
<div className="rounded-full bg-gray-100 p-4 mb-4">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-1">Service not installed</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
This service isn't installed yet. Visit the Services catalog to install it.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
This service hasn't been set up yet. Contact your cell administrator.
|
||||
</p>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/services"
|
||||
className="mt-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors"
|
||||
>
|
||||
Go to Services catalog
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Copy, Download, Wifi, Mail, Calendar, FolderOpen } from 'lucide-react';
|
||||
import { peerAPI } from '../services/api';
|
||||
import { Copy, Download, Wifi, Mail, Calendar, FolderOpen, Package } from 'lucide-react';
|
||||
import { peerAPI, servicesAPI } from '../services/api';
|
||||
|
||||
function CopyButton({ text }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -39,6 +39,7 @@ export default function MyServices() {
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeServiceIds, setActiveServiceIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
peerAPI.services()
|
||||
@@ -47,6 +48,12 @@ export default function MyServices() {
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
servicesAPI.listActive()
|
||||
.then(resp => setActiveServiceIds((resp.data || []).map(s => s.id)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const downloadConfig = (filename, content) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -114,52 +121,81 @@ export default function MyServices() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Mail className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Email</h2>
|
||||
{activeServiceIds.length === 0 && (
|
||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
|
||||
No optional services are installed yet. Contact your administrator.
|
||||
</div>
|
||||
<InfoRow label="Address" value={email.address || '—'} />
|
||||
<InfoRow label="SMTP" value={email.smtp ? `${email.smtp.host}:${email.smtp.port}` : '—'} />
|
||||
<InfoRow label="IMAP" value={email.imap ? `${email.imap.host}:${email.imap.port}` : '—'} />
|
||||
{(email.smtp || email.imap) && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
When setting up your mail client, use your dashboard username and password for authentication.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Calendar & Contacts</h2>
|
||||
{activeServiceIds.includes('email') ? (
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Mail className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Email</h2>
|
||||
</div>
|
||||
<InfoRow label="Address" value={email.address || '—'} />
|
||||
<InfoRow label="SMTP" value={email.smtp ? `${email.smtp.host}:${email.smtp.port}` : '—'} />
|
||||
<InfoRow label="IMAP" value={email.imap ? `${email.imap.host}:${email.imap.port}` : '—'} />
|
||||
{(email.smtp || email.imap) && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
When setting up your mail client, use your dashboard username and password for authentication.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<InfoRow label="CalDAV URL" value={caldav.url || '—'} />
|
||||
<InfoRow label="Username" value={caldav.username || '—'} />
|
||||
{caldav.url && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Use this URL in your calendar client. Authenticate with your username and dashboard password.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FolderOpen className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Files</h2>
|
||||
) : (
|
||||
<div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
|
||||
<Package className="h-6 w-6 text-gray-300" />
|
||||
<p className="text-sm text-gray-400">Email — not installed</p>
|
||||
</div>
|
||||
<InfoRow label="WebDAV URL" value={files.url || '—'} />
|
||||
<InfoRow label="Username" value={files.username || '—'} />
|
||||
{files.url && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 mt-4">
|
||||
Note: Changing your dashboard password does not update email, calendar, or files passwords.
|
||||
</p>
|
||||
{activeServiceIds.includes('calendar') ? (
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Calendar & Contacts</h2>
|
||||
</div>
|
||||
<InfoRow label="CalDAV URL" value={caldav.url || '—'} />
|
||||
<InfoRow label="Username" value={caldav.username || '—'} />
|
||||
{caldav.url && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Use this URL in your calendar client. Authenticate with your username and dashboard password.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
|
||||
<Package className="h-6 w-6 text-gray-300" />
|
||||
<p className="text-sm text-gray-400">Calendar & Contacts — not installed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeServiceIds.includes('files') ? (
|
||||
<div className="card mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FolderOpen className="h-5 w-5 text-primary-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Files</h2>
|
||||
</div>
|
||||
<InfoRow label="WebDAV URL" value={files.url || '—'} />
|
||||
<InfoRow label="Username" value={files.username || '—'} />
|
||||
{files.url && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Mount this URL as a network drive or use a WebDAV client. Authenticate with your username and dashboard password.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-8 text-center gap-2 mb-4">
|
||||
<Package className="h-6 w-6 text-gray-300" />
|
||||
<p className="text-sm text-gray-400">Files — not installed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeServiceIds.includes('email') || activeServiceIds.includes('calendar') || activeServiceIds.includes('files')) && (
|
||||
<p className="text-xs text-gray-400 mt-4">
|
||||
Note: Changing your dashboard password does not update email, calendar, or files passwords.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Package,
|
||||
Download,
|
||||
@@ -7,10 +6,6 @@ import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Mail,
|
||||
Calendar as CalendarIcon,
|
||||
FolderOpen,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { storeAPI } from '../services/api';
|
||||
|
||||
@@ -64,7 +59,7 @@ function SkeletonCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
|
||||
function ConfirmUninstallDialog({ service, onConfirm, onCancel }) {
|
||||
const [purge, setPurge] = useState(false);
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
@@ -72,7 +67,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Remove {service.name}?</h3>
|
||||
<h3 className="font-semibold text-gray-900">Uninstall {service.name}?</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
The service will be stopped and uninstalled. By default, data is kept on disk.
|
||||
</p>
|
||||
@@ -93,7 +88,7 @@ function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
|
||||
onClick={() => onConfirm(purge)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
|
||||
>
|
||||
{purge ? 'Remove and Delete Data' : 'Remove Service'}
|
||||
{purge ? 'Uninstall and Delete Data' : 'Uninstall Service'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +127,7 @@ function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRe
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
>
|
||||
{removing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{removing ? 'Removing…' : 'Remove'}
|
||||
{removing ? 'Uninstalling…' : 'Uninstall'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -149,38 +144,6 @@ function StoreServiceCard({ service, isInstalled, installedInfo, onInstall, onRe
|
||||
);
|
||||
}
|
||||
|
||||
// Cards for core services (always present, not removable)
|
||||
const CORE_SERVICES = [
|
||||
{ name: 'Email', href: '/services/email', icon: Mail, desc: 'Postfix (SMTP) + Dovecot (IMAP) email server' },
|
||||
{ name: 'Calendar & Contacts', href: '/services/calendar', icon: CalendarIcon, desc: 'Radicale CalDAV / CardDAV server' },
|
||||
{ name: 'File Storage', href: '/services/files', icon: FolderOpen, desc: 'FileGator browser UI + WebDAV network drive' },
|
||||
];
|
||||
|
||||
function CoreServiceCard({ svc }) {
|
||||
return (
|
||||
<div className="card flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svc.icon className="h-5 w-5 text-primary-500 shrink-0" />
|
||||
<span className="font-semibold text-gray-900 truncate">{svc.name}</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-primary-700 bg-primary-50 border border-primary-200 rounded-full px-2 py-0.5 shrink-0">
|
||||
Core
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 flex-1">{svc.desc}</p>
|
||||
<div className="flex justify-end pt-1 border-t border-gray-100">
|
||||
<Link
|
||||
to={svc.href}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServicesIndex() {
|
||||
const toasts = useToasts();
|
||||
@@ -231,6 +194,7 @@ export default function ServicesIndex() {
|
||||
try {
|
||||
await storeAPI.installService(service.id);
|
||||
toastEvent(`${service.name} installed successfully`);
|
||||
window.dispatchEvent(new CustomEvent('pic-services-changed'));
|
||||
await loadStore();
|
||||
} catch (err) {
|
||||
toastEvent(err.response?.data?.error || `Failed to install ${service.name}`, 'error');
|
||||
@@ -245,10 +209,11 @@ export default function ServicesIndex() {
|
||||
setOpState((s) => ({ ...s, [service.id]: 'removing' }));
|
||||
try {
|
||||
await storeAPI.removeService(service.id, purge);
|
||||
toastEvent(`${service.name} removed`);
|
||||
toastEvent(`${service.name} uninstalled`);
|
||||
window.dispatchEvent(new CustomEvent('pic-services-changed'));
|
||||
await loadStore();
|
||||
} catch (err) {
|
||||
toastEvent(err.response?.data?.error || `Failed to remove ${service.name}`, 'error');
|
||||
toastEvent(err.response?.data?.error || `Failed to uninstall ${service.name}`, 'error');
|
||||
} finally {
|
||||
setOpState((s) => ({ ...s, [service.id]: null }));
|
||||
}
|
||||
@@ -265,7 +230,7 @@ export default function ServicesIndex() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage built-in services and browse optional add-ons
|
||||
Install and manage optional services for your cell
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -278,16 +243,6 @@ export default function ServicesIndex() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Core services — always shown */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Built-in ({CORE_SERVICES.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{CORE_SERVICES.map((svc) => <CoreServiceCard key={svc.name} svc={svc} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add-ons from store */}
|
||||
{isLoading && (
|
||||
<div>
|
||||
@@ -369,7 +324,7 @@ export default function ServicesIndex() {
|
||||
)}
|
||||
|
||||
{removeTarget && (
|
||||
<ConfirmRemoveDialog
|
||||
<ConfirmUninstallDialog
|
||||
service={removeTarget}
|
||||
onConfirm={handleRemoveConfirm}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Calendar as CalendarIcon, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { calendarAPI, cellAPI, peerAPI } from '../../services/api';
|
||||
import { calendarAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api';
|
||||
import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner';
|
||||
import { useConfig } from '../../contexts/ConfigContext';
|
||||
import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -69,6 +70,7 @@ function Toast({ msg, type }) {
|
||||
export default function CalendarPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [notInstalled, setNotInstalled] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
@@ -88,6 +90,15 @@ export default function CalendarPage() {
|
||||
const [toast, setToast] = useState(null);
|
||||
const [peerData, setPeerData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
servicesAPI.listActive()
|
||||
.then(resp => {
|
||||
const ids = (resp.data || []).map(s => s.id);
|
||||
setNotInstalled(!ids.includes('calendar'));
|
||||
})
|
||||
.catch(() => setNotInstalled(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar });
|
||||
}, [service_configs.calendar]);
|
||||
@@ -158,6 +169,14 @@ export default function CalendarPage() {
|
||||
draftConfig?.setDirty('calendar', true);
|
||||
};
|
||||
|
||||
if (notInstalled === true) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<ServiceNotInstalledBanner isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Mail, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { emailAPI, cellAPI, peerAPI } from '../../services/api';
|
||||
import { emailAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api';
|
||||
import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner';
|
||||
import { useConfig } from '../../contexts/ConfigContext';
|
||||
import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -79,6 +80,7 @@ function Toast({ msg, type }) {
|
||||
export default function EmailPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [notInstalled, setNotInstalled] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
@@ -104,6 +106,15 @@ export default function EmailPage() {
|
||||
// Peer state
|
||||
const [peerData, setPeerData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
servicesAPI.listActive()
|
||||
.then(resp => {
|
||||
const ids = (resp.data || []).map(s => s.id);
|
||||
setNotInstalled(!ids.includes('email'));
|
||||
})
|
||||
.catch(() => setNotInstalled(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email });
|
||||
}, [service_configs.email]);
|
||||
@@ -180,6 +191,14 @@ export default function EmailPage() {
|
||||
draftConfig?.setDirty('email', true);
|
||||
};
|
||||
|
||||
if (notInstalled === true) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<ServiceNotInstalledBanner isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { FolderOpen, Users, HardDrive, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { fileAPI, cellAPI, peerAPI } from '../../services/api';
|
||||
import { fileAPI, cellAPI, peerAPI, servicesAPI } from '../../services/api';
|
||||
import ServiceNotInstalledBanner from '../../components/ServiceNotInstalledBanner';
|
||||
import { useConfig } from '../../contexts/ConfigContext';
|
||||
import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -76,6 +77,7 @@ function Toast({ msg, type }) {
|
||||
export default function FilesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [notInstalled, setNotInstalled] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
@@ -98,6 +100,15 @@ export default function FilesPage() {
|
||||
const [toast, setToast] = useState(null);
|
||||
const [peerData, setPeerData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
servicesAPI.listActive()
|
||||
.then(resp => {
|
||||
const ids = (resp.data || []).map(s => s.id);
|
||||
setNotInstalled(!ids.includes('files'));
|
||||
})
|
||||
.catch(() => setNotInstalled(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files });
|
||||
}, [service_configs.files]);
|
||||
@@ -169,6 +180,14 @@ export default function FilesPage() {
|
||||
draftConfig?.setDirty('files', true);
|
||||
};
|
||||
|
||||
if (notInstalled === true) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<ServiceNotInstalledBanner isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
@@ -271,6 +271,7 @@ export const servicesAPI = {
|
||||
startService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/start`),
|
||||
stopService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/stop`),
|
||||
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`),
|
||||
listActive: () => api.get('/api/services/active'),
|
||||
};
|
||||
|
||||
// Accounts API (peer service account provisioning via AccountManager)
|
||||
|
||||
Reference in New Issue
Block a user