Phase 4: service store — manifest validation, install/remove, Store UI
- ServiceStoreManager: manifest allowlist (git.pic.ngo/roof/*), volume
denylist, ACCEPT-only iptables rules, ${SERVICE_IP}-only dest_ip
- IP allocator: pool 172.20.0.20-254, skips CONTAINER_OFFSETS VIPs
- Compose overlay: docker-compose.services.yml auto-included via DCF
- Flask blueprint at /api/store: list, install, remove, refresh
- Store.jsx: full install/remove UI with spinners and toast notifications
- 95 new unit tests for ServiceStoreManager (all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Package,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { storeAPI } from '../services/api';
|
||||
|
||||
// ── Toast helpers (same pattern as Settings.jsx) ─────────────────────────────
|
||||
|
||||
function toastEvent(msg, type = 'success') {
|
||||
window.dispatchEvent(new CustomEvent('store-toast', { detail: { msg, type } }));
|
||||
}
|
||||
|
||||
function Toast({ toasts }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
|
||||
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'success' ? (
|
||||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{t.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useToasts() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { ...e.detail, id }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
||||
};
|
||||
window.addEventListener('store-toast', handler);
|
||||
return () => window.removeEventListener('store-toast', handler);
|
||||
}, []);
|
||||
return toasts;
|
||||
}
|
||||
|
||||
// ── Skeleton card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="card animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
|
||||
<div className="h-3 bg-gray-100 rounded w-3/4 mb-1" />
|
||||
<div className="h-3 bg-gray-100 rounded w-1/2 mb-4" />
|
||||
<div className="flex justify-between items-center mt-auto">
|
||||
<div className="h-3 bg-gray-100 rounded w-1/4" />
|
||||
<div className="h-8 bg-gray-200 rounded w-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Confirm remove dialog ─────────────────────────────────────────────────────
|
||||
|
||||
function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
|
||||
const [purge, setPurge] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-96 mx-4">
|
||||
<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>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
The service will be stopped and uninstalled. By default, data is kept on disk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none mb-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={purge}
|
||||
onChange={(e) => setPurge(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-400"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Also delete service data (cannot be undone)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Service card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ServiceCard({ service, isInstalled, installedInfo, onInstall, onRemove, installing, removing }) {
|
||||
return (
|
||||
<div className="card flex flex-col gap-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Package className="h-5 w-5 text-primary-500 shrink-0" />
|
||||
<span className="font-semibold text-gray-900 truncate">{service.name}</span>
|
||||
</div>
|
||||
{isInstalled && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5 shrink-0">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-500 flex-1">
|
||||
{service.description || 'No description available.'}
|
||||
</p>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
|
||||
{service.version && (
|
||||
<span>v{service.version}</span>
|
||||
)}
|
||||
{service.author && (
|
||||
<span>by {service.author}</span>
|
||||
)}
|
||||
{isInstalled && installedInfo?.installed_at && (
|
||||
<span>Installed {new Date(installedInfo.installed_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-end pt-1 border-t border-gray-100">
|
||||
{isInstalled ? (
|
||||
<button
|
||||
onClick={() => onRemove(service)}
|
||||
disabled={removing}
|
||||
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"
|
||||
aria-label={`Remove ${service.name}`}
|
||||
>
|
||||
{removing ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{removing ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onInstall(service)}
|
||||
disabled={installing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
aria-label={`Install ${service.name}`}
|
||||
>
|
||||
{installing ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{installing ? 'Installing…' : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Store component ──────────────────────────────────────────────────────
|
||||
|
||||
function Store() {
|
||||
const toasts = useToasts();
|
||||
|
||||
const [services, setServices] = useState([]); // available services array
|
||||
const [installed, setInstalled] = useState({}); // map of id -> installed info
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Per-service operation state: { [id]: 'installing' | 'removing' | null }
|
||||
const [opState, setOpState] = useState({});
|
||||
|
||||
// Pending remove confirmation dialog
|
||||
const [removeTarget, setRemoveTarget] = useState(null); // service object or null
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await storeAPI.listServices();
|
||||
const data = res.data || {};
|
||||
setServices(Array.isArray(data.available) ? data.available : []);
|
||||
setInstalled(data.installed && typeof data.installed === 'object' ? data.installed : {});
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Could not load the service store. Check that the API is reachable.';
|
||||
setLoadError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStore();
|
||||
}, [loadStore]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await storeAPI.refreshIndex();
|
||||
toastEvent('Store index refreshed');
|
||||
await loadStore();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to refresh store index';
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (service) => {
|
||||
setOpState((s) => ({ ...s, [service.id]: 'installing' }));
|
||||
try {
|
||||
await storeAPI.installService(service.id);
|
||||
toastEvent(`${service.name} installed successfully`);
|
||||
await loadStore();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
`Failed to install ${service.name}`;
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setOpState((s) => ({ ...s, [service.id]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveClick = (service) => {
|
||||
setRemoveTarget(service);
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async (purge) => {
|
||||
const service = removeTarget;
|
||||
setRemoveTarget(null);
|
||||
setOpState((s) => ({ ...s, [service.id]: 'removing' }));
|
||||
try {
|
||||
await storeAPI.removeService(service.id, purge);
|
||||
toastEvent(`${service.name} removed`);
|
||||
await loadStore();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
`Failed to remove ${service.name}`;
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setOpState((s) => ({ ...s, [service.id]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const installedServices = services.filter((s) => installed[s.id]);
|
||||
const availableServices = services.filter((s) => !installed[s.id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Service Store</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Browse and install optional services for your Personal Internet Cell
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || isLoading}
|
||||
className="btn-secondary flex items-center gap-2 text-sm shrink-0"
|
||||
aria-label="Refresh store index"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? 'Refreshing…' : 'Refresh Store'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div>
|
||||
<div className="h-4 bg-gray-200 rounded w-40 mb-4 animate-pulse" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((n) => (
|
||||
<SkeletonCard key={n} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!isLoading && loadError && (
|
||||
<div className="card border border-red-200 bg-red-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">Failed to load store</p>
|
||||
<p className="text-sm text-red-600 mt-1">{loadError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsLoading(true); loadStore(); }}
|
||||
className="btn-secondary text-sm shrink-0"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && !loadError && (
|
||||
<>
|
||||
{/* Installed services section */}
|
||||
{installedServices.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Installed ({installedServices.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{installedServices.map((svc) => (
|
||||
<ServiceCard
|
||||
key={svc.id}
|
||||
service={svc}
|
||||
isInstalled={true}
|
||||
installedInfo={installed[svc.id]}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemoveClick}
|
||||
installing={opState[svc.id] === 'installing'}
|
||||
removing={opState[svc.id] === 'removing'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Available services section */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{installedServices.length > 0 ? 'Available to Install' : 'Available Services'}
|
||||
{availableServices.length > 0 && ` (${availableServices.length})`}
|
||||
</h2>
|
||||
|
||||
{availableServices.length === 0 && installedServices.length === 0 && (
|
||||
<div className="card border border-gray-100 text-center py-12">
|
||||
<Package className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-500">No services in the store yet</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Click "Refresh Store" to check for available services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableServices.length === 0 && installedServices.length > 0 && (
|
||||
<div className="card border border-gray-100 text-center py-8">
|
||||
<CheckCircle className="h-8 w-8 text-green-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">All available services are installed.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableServices.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableServices.map((svc) => (
|
||||
<ServiceCard
|
||||
key={svc.id}
|
||||
service={svc}
|
||||
isInstalled={false}
|
||||
installedInfo={null}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemoveClick}
|
||||
installing={opState[svc.id] === 'installing'}
|
||||
removing={opState[svc.id] === 'removing'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remove confirmation dialog */}
|
||||
{removeTarget && (
|
||||
<ConfirmRemoveDialog
|
||||
service={removeTarget}
|
||||
onConfirm={handleRemoveConfirm}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Store;
|
||||
Reference in New Issue
Block a user