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:
2026-05-09 10:19:39 -04:00
parent f77d7fabcd
commit 0a21f22076
14 changed files with 2190 additions and 12 deletions
+429
View File
@@ -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;