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:
@@ -17,6 +17,9 @@
|
||||
# Detect docker compose command (v2 plugin preferred, fallback to v1 standalone)
|
||||
DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
|
||||
|
||||
# Full compose command: includes docker-compose.services.yml when it exists
|
||||
DCF = $(DC) $(if $(wildcard docker-compose.services.yml),-f docker-compose.yml -f docker-compose.services.yml,-f docker-compose.yml)
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Personal Internet Cell - Management Commands"
|
||||
@@ -93,12 +96,12 @@ init-peers:
|
||||
|
||||
start:
|
||||
@echo "Starting Personal Internet Cell..."
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build
|
||||
@echo "Services started. Check status with 'make status'"
|
||||
|
||||
stop:
|
||||
@echo "Stopping Personal Internet Cell..."
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down
|
||||
@echo "Services stopped."
|
||||
|
||||
restart:
|
||||
@@ -109,16 +112,16 @@ restart:
|
||||
status:
|
||||
@echo "Personal Internet Cell Status:"
|
||||
@echo "================================"
|
||||
$(DC) ps
|
||||
$(DCF) ps
|
||||
@echo ""
|
||||
@echo "API Status:"
|
||||
@curl -s http://localhost:3000/health || echo "API not responding"
|
||||
|
||||
logs:
|
||||
$(DC) logs -f
|
||||
$(DCF) logs -f
|
||||
|
||||
logs-%:
|
||||
$(DC) logs -f $*
|
||||
$(DCF) logs -f $*
|
||||
|
||||
shell-%:
|
||||
docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh
|
||||
@@ -135,12 +138,12 @@ update:
|
||||
$(MAKE) setup; \
|
||||
fi
|
||||
@echo "Rebuilding and restarting services..."
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build
|
||||
@echo "Update complete. Run 'make status' to verify."
|
||||
|
||||
reinstall:
|
||||
@echo "Reinstalling Personal Internet Cell from scratch..."
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v 2>/dev/null || true
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v 2>/dev/null || true
|
||||
@sudo rm -rf config/ data/
|
||||
@$(MAKE) setup
|
||||
@$(MAKE) start
|
||||
@@ -169,14 +172,14 @@ uninstall:
|
||||
case "$$ans" in \
|
||||
y|Y) \
|
||||
echo "Stopping containers and removing images..."; \
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v --rmi all 2>/dev/null || true; \
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v --rmi all 2>/dev/null || true; \
|
||||
echo "Deleting config/ and data/..."; \
|
||||
sudo rm -rf config/ data/; \
|
||||
echo "Uninstall complete. Git repo and scripts remain."; \
|
||||
;; \
|
||||
n|N|"") \
|
||||
echo "Stopping and removing containers (keeping images and data)..."; \
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down 2>/dev/null || true; \
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down 2>/dev/null || true; \
|
||||
echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \
|
||||
;; \
|
||||
*) \
|
||||
@@ -208,7 +211,7 @@ build-webui:
|
||||
|
||||
start-core:
|
||||
@echo "Starting core services (caddy, dns, wireguard, api, webui)..."
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile core up -d --build
|
||||
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build
|
||||
@echo "Core services started. Run 'make start' to also bring up optional services."
|
||||
|
||||
start-dns:
|
||||
|
||||
+9
-1
@@ -42,7 +42,7 @@ from managers import (
|
||||
routing_manager, vault_manager, container_manager,
|
||||
cell_link_manager, auth_manager, setup_manager,
|
||||
caddy_manager,
|
||||
ddns_manager,
|
||||
ddns_manager, service_store_manager,
|
||||
firewall_manager, EventType,
|
||||
)
|
||||
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
|
||||
@@ -374,6 +374,11 @@ def _apply_startup_enforcement():
|
||||
sync_summary = cell_link_manager.replay_pending_pushes()
|
||||
if sync_summary.get('attempted'):
|
||||
logger.info(f"Startup permission sync: {sync_summary}")
|
||||
# Service store: re-apply firewall/caddy rules for installed services
|
||||
try:
|
||||
service_store_manager.reapply_on_startup()
|
||||
except Exception as _sse:
|
||||
logger.warning(f"service_store reapply_on_startup failed (non-fatal): {_sse}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
|
||||
|
||||
@@ -465,6 +470,9 @@ app.register_blueprint(_services_bp)
|
||||
app.register_blueprint(_peer_dashboard_bp)
|
||||
app.register_blueprint(_config_bp)
|
||||
|
||||
from routes.service_store import store_bp
|
||||
app.register_blueprint(store_bp)
|
||||
|
||||
# Re-export config helpers so existing test imports/patches keep working
|
||||
from routes.config import (
|
||||
_set_pending_restart, _clear_pending_restart,
|
||||
|
||||
@@ -373,6 +373,12 @@ class CaddyManager(BaseServiceManager):
|
||||
|
||||
# ── certificate status ────────────────────────────────────────────────
|
||||
|
||||
def regenerate_with_installed(self, installed_services: list) -> bool:
|
||||
"""Regenerate Caddyfile with installed services and reload."""
|
||||
identity = self.config_manager.get_identity()
|
||||
content = self.generate_caddyfile(identity, installed_services)
|
||||
return self.write_caddyfile(content)
|
||||
|
||||
def get_cert_status(self) -> Dict[str, Any]:
|
||||
"""Return TLS cert status from identity['tls'] if present."""
|
||||
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
||||
|
||||
@@ -474,6 +474,20 @@ class ConfigManager:
|
||||
self.configs['_identity'][key] = value
|
||||
self._save_all_configs()
|
||||
|
||||
def get_installed_services(self) -> dict:
|
||||
return self.configs.get('_identity', {}).get('installed_services', {})
|
||||
|
||||
def set_installed_service(self, service_id: str, record: dict):
|
||||
ident = self.configs.setdefault('_identity', {})
|
||||
ident.setdefault('installed_services', {})[service_id] = record
|
||||
self._save_all_configs()
|
||||
|
||||
def remove_installed_service(self, service_id: str):
|
||||
ident = self.configs.setdefault('_identity', {})
|
||||
ident.setdefault('installed_services', {}).pop(service_id, None)
|
||||
ident.setdefault('service_ips', {}).pop(service_id, None)
|
||||
self._save_all_configs()
|
||||
|
||||
def get_all_configs(self) -> Dict[str, Dict]:
|
||||
"""Get all service configurations"""
|
||||
return self.configs.copy()
|
||||
|
||||
@@ -804,3 +804,52 @@ def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFI
|
||||
if ok:
|
||||
reload_coredns()
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service store firewall rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _service_tag(service_id: str) -> str:
|
||||
safe = re.sub(r'[^a-z0-9]', '-', service_id.lower())
|
||||
return f'pic-svc-{safe}'
|
||||
|
||||
|
||||
def apply_service_rules(service_id: str, service_ip: str, rules: list) -> bool:
|
||||
"""Apply manifest-declared ACCEPT rules for an installed service."""
|
||||
tag = _service_tag(service_id)
|
||||
clear_service_rules(service_id)
|
||||
for r in rules:
|
||||
if r.get('type') != 'ACCEPT':
|
||||
continue
|
||||
dest_ip = r['dest_ip'].replace('${SERVICE_IP}', service_ip)
|
||||
dport = str(r['dest_port'])
|
||||
proto = r.get('proto', 'tcp')
|
||||
_iptables(['-I', 'FORWARD',
|
||||
'-d', dest_ip, '-p', proto, '--dport', dport,
|
||||
'-m', 'comment', '--comment', tag,
|
||||
'-j', 'ACCEPT'])
|
||||
return True
|
||||
|
||||
|
||||
def clear_service_rules(service_id: str) -> None:
|
||||
"""Remove all iptables rules tagged for this service using save/restore."""
|
||||
tag = _service_tag(service_id)
|
||||
comment_re = re.compile(rf'--comment\s+["\']?{re.escape(tag)}["\']?(\s|$)')
|
||||
try:
|
||||
save = _wg_exec(['iptables-save'])
|
||||
if save.returncode != 0:
|
||||
return
|
||||
lines = save.stdout.splitlines()
|
||||
filtered = [l for l in lines if not comment_re.search(l)]
|
||||
if len(filtered) == len(lines):
|
||||
return
|
||||
restore_input = '\n'.join(filtered) + '\n'
|
||||
restore = subprocess.run(
|
||||
['docker', 'exec', '-i', WIREGUARD_CONTAINER, 'iptables-restore'],
|
||||
input=restore_input, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if restore.returncode != 0:
|
||||
logger.warning(f'clear_service_rules iptables-restore failed: {restore.stderr.strip()}')
|
||||
except Exception as e:
|
||||
logger.error(f'clear_service_rules({service_id}): {e}')
|
||||
|
||||
+10
-1
@@ -60,6 +60,15 @@ setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_ma
|
||||
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
|
||||
from service_store_manager import ServiceStoreManager
|
||||
service_store_manager = ServiceStoreManager(
|
||||
config_manager=config_manager,
|
||||
caddy_manager=caddy_manager,
|
||||
container_manager=container_manager,
|
||||
data_dir=DATA_DIR,
|
||||
config_dir=CONFIG_DIR,
|
||||
)
|
||||
|
||||
# Service logger configuration
|
||||
_service_log_configs = {
|
||||
'network': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
||||
@@ -93,7 +102,7 @@ __all__ = [
|
||||
'email_manager', 'calendar_manager', 'file_manager',
|
||||
'routing_manager', 'vault_manager', 'container_manager',
|
||||
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
||||
'ddns_manager',
|
||||
'ddns_manager', 'service_store_manager',
|
||||
'firewall_manager', 'EventType',
|
||||
'DATA_DIR', 'CONFIG_DIR',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Service Store Blueprint — /api/store
|
||||
|
||||
Provides routes to browse, install, and remove services from the PIC
|
||||
service store. Authentication is enforced by the global before_request
|
||||
hook in app.py (admin session required for all /api/* routes except
|
||||
/api/auth/*).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
import requests as _requests
|
||||
|
||||
from service_store_manager import MANIFEST_URL_TPL
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
store_bp = Blueprint('service_store', __name__, url_prefix='/api/store')
|
||||
|
||||
|
||||
def _ssm():
|
||||
"""Lazy import of service_store_manager to avoid circular import at module load."""
|
||||
from app import service_store_manager
|
||||
return service_store_manager
|
||||
|
||||
|
||||
def _cfg():
|
||||
from app import config_manager
|
||||
return config_manager
|
||||
|
||||
|
||||
@store_bp.route('/services', methods=['GET'])
|
||||
def list_store_services():
|
||||
"""Return available and installed services."""
|
||||
try:
|
||||
return jsonify(_ssm().list_services())
|
||||
except Exception as e:
|
||||
logger.error(f'list_store_services: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@store_bp.route('/services/<service_id>/manifest', methods=['GET'])
|
||||
def get_manifest(service_id: str):
|
||||
"""Fetch and return the manifest for a specific service."""
|
||||
try:
|
||||
url = MANIFEST_URL_TPL.format(id=service_id)
|
||||
resp = _requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return jsonify(resp.json())
|
||||
except _requests.HTTPError as e:
|
||||
return jsonify({'error': f'Manifest not found: {e}'}), 404
|
||||
except Exception as e:
|
||||
logger.error(f'get_manifest({service_id}): {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@store_bp.route('/services/<service_id>/install', methods=['POST'])
|
||||
def install_service(service_id: str):
|
||||
"""Install a service from the store."""
|
||||
try:
|
||||
result = _ssm().install(service_id)
|
||||
if result.get('ok'):
|
||||
return jsonify(result)
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
logger.error(f'install_service({service_id}): {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@store_bp.route('/services/<service_id>', methods=['DELETE'])
|
||||
def remove_service(service_id: str):
|
||||
"""Remove an installed service."""
|
||||
try:
|
||||
purge = request.args.get('purge') == 'true'
|
||||
result = _ssm().remove(service_id, purge_data=purge)
|
||||
if result.get('ok'):
|
||||
return jsonify(result)
|
||||
return jsonify(result), 404
|
||||
except Exception as e:
|
||||
logger.error(f'remove_service({service_id}): {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@store_bp.route('/installed', methods=['GET'])
|
||||
def get_installed():
|
||||
"""Return all currently installed services."""
|
||||
try:
|
||||
return jsonify({'installed': _cfg().get_installed_services()})
|
||||
except Exception as e:
|
||||
logger.error(f'get_installed: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@store_bp.route('/refresh', methods=['POST'])
|
||||
def refresh_index():
|
||||
"""Invalidate the index cache and return a fresh service list."""
|
||||
try:
|
||||
ssm = _ssm()
|
||||
ssm._index_cache = None
|
||||
ssm._index_cache_time = 0
|
||||
return jsonify(ssm.list_services())
|
||||
except Exception as e:
|
||||
logger.error(f'refresh_index: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Service Store Manager for Personal Internet Cell.
|
||||
|
||||
Manages installation, removal, and lifecycle of third-party services from the
|
||||
PIC service store index. Each installed service runs as a Docker container
|
||||
declared in a compose override file and has:
|
||||
- An allocated IP in the service pool (172.20.0.20–254 by default)
|
||||
- Optional iptables FORWARD rules declared in its manifest
|
||||
- Optional Caddy reverse-proxy route declared in its manifest
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from base_service_manager import BaseServiceManager
|
||||
from ip_utils import CONTAINER_OFFSETS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_POOL_START = 20
|
||||
SERVICE_POOL_END = 254
|
||||
|
||||
INDEX_URL_DEFAULT = (
|
||||
'https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json'
|
||||
)
|
||||
MANIFEST_URL_TPL = (
|
||||
'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json'
|
||||
)
|
||||
|
||||
IMAGE_ALLOWLIST_RE = re.compile(
|
||||
r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?$'
|
||||
)
|
||||
FORBIDDEN_MOUNTS = frozenset([
|
||||
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
|
||||
])
|
||||
RESERVED_SUBDOMAINS = frozenset([
|
||||
'api', 'webui', 'admin', 'www', 'mail', 'ns1', 'ns2',
|
||||
'git', 'registry', 'install',
|
||||
])
|
||||
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ServiceStoreManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ServiceStoreManager(BaseServiceManager):
|
||||
"""Manages service store: install, remove, and list available/installed services."""
|
||||
|
||||
def __init__(self, config_manager, caddy_manager, container_manager,
|
||||
data_dir: str = '', config_dir: str = ''):
|
||||
super().__init__('service_store', data_dir, config_dir)
|
||||
self.config_manager = config_manager
|
||||
self.caddy_manager = caddy_manager
|
||||
self.container_manager = container_manager
|
||||
self.compose_override = os.environ.get(
|
||||
'COMPOSE_SERVICES_PATH', '/app/docker-compose.services.yml'
|
||||
)
|
||||
self.index_url = os.environ.get('PIC_STORE_INDEX_URL', INDEX_URL_DEFAULT)
|
||||
self._lock = threading.Lock()
|
||||
self._index_cache: Optional[list] = None
|
||||
self._index_cache_time: float = 0
|
||||
self._cache_ttl: int = 300 # 5 min
|
||||
|
||||
# ── BaseServiceManager required ───────────────────────────────────────
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
installed = self.config_manager.get_installed_services()
|
||||
return {
|
||||
'service': self.service_name,
|
||||
'running': True,
|
||||
'installed_count': len(installed),
|
||||
}
|
||||
|
||||
def test_connectivity(self) -> Dict[str, Any]:
|
||||
try:
|
||||
resp = requests.get(self.index_url, timeout=5)
|
||||
return {'success': resp.status_code == 200}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# ── Manifest validation ───────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _validate_manifest(m: dict) -> Tuple[bool, List[str]]:
|
||||
"""Validate a service manifest. Returns (ok, [errors])."""
|
||||
errors: List[str] = []
|
||||
|
||||
# Required top-level fields
|
||||
for field in ('id', 'name', 'version', 'author', 'image', 'container_name'):
|
||||
if not m.get(field):
|
||||
errors.append(f'Missing required field: {field}')
|
||||
|
||||
# Image allowlist
|
||||
image = m.get('image', '')
|
||||
if image and not IMAGE_ALLOWLIST_RE.match(image):
|
||||
errors.append(
|
||||
f'image must match git.pic.ngo/roof/* pattern, got: {image}'
|
||||
)
|
||||
|
||||
# Volume mount safety
|
||||
for vol in m.get('volumes', []):
|
||||
mount = vol.get('mount', '')
|
||||
if mount in FORBIDDEN_MOUNTS:
|
||||
errors.append(f'Forbidden volume mount: {mount}')
|
||||
elif mount.startswith('/home/roof/pic'):
|
||||
errors.append(f'Volume mount cannot be a prefix of /home/roof/pic: {mount}')
|
||||
|
||||
# iptables rules
|
||||
for rule in m.get('iptables_rules', []):
|
||||
if rule.get('type') != 'ACCEPT':
|
||||
errors.append(
|
||||
f'iptables_rules[].type must be ACCEPT, got: {rule.get("type")}'
|
||||
)
|
||||
if rule.get('dest_ip') != '${SERVICE_IP}':
|
||||
errors.append(
|
||||
f'iptables_rules[].dest_ip must be exactly ${{SERVICE_IP}}, '
|
||||
f'got: {rule.get("dest_ip")}'
|
||||
)
|
||||
port = rule.get('dest_port')
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
errors.append(
|
||||
f'iptables_rules[].dest_port must be an integer 1-65535, got: {port}'
|
||||
)
|
||||
proto = rule.get('proto', 'tcp')
|
||||
if proto not in ('tcp', 'udp'):
|
||||
errors.append(
|
||||
f'iptables_rules[].proto must be tcp or udp, got: {proto}'
|
||||
)
|
||||
|
||||
# Caddy route subdomain
|
||||
caddy_route = m.get('caddy_route') or {}
|
||||
if isinstance(caddy_route, dict):
|
||||
subdomain = caddy_route.get('subdomain', '')
|
||||
else:
|
||||
subdomain = ''
|
||||
if subdomain:
|
||||
if subdomain in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'caddy_route.subdomain is reserved: {subdomain}')
|
||||
elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain):
|
||||
errors.append(
|
||||
f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, '
|
||||
f'got: {subdomain}'
|
||||
)
|
||||
|
||||
# Env value safety
|
||||
for env_entry in m.get('env', []):
|
||||
val = str(env_entry.get('value', ''))
|
||||
if not ENV_VALUE_RE.match(val):
|
||||
errors.append(
|
||||
f'env[].value contains disallowed characters: {val!r}'
|
||||
)
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
# ── IP allocation ─────────────────────────────────────────────────────
|
||||
|
||||
def _allocate_service_ip(self, service_id: str) -> str:
|
||||
"""Allocate the next free IP from the service pool."""
|
||||
identity = self.config_manager.get_identity()
|
||||
ip_range = identity.get('ip_range', '172.20.0.0/16')
|
||||
|
||||
import ipaddress
|
||||
network = ipaddress.IPv4Network(ip_range, strict=False)
|
||||
base = int(network.network_address)
|
||||
|
||||
# IPs already assigned to named containers
|
||||
reserved_offsets = set(CONTAINER_OFFSETS.values())
|
||||
|
||||
# IPs already assigned to installed services
|
||||
service_ips: Dict[str, str] = identity.get('service_ips', {})
|
||||
taken_ips = set(service_ips.values())
|
||||
|
||||
for offset in range(SERVICE_POOL_START, SERVICE_POOL_END + 1):
|
||||
if offset in reserved_offsets:
|
||||
continue
|
||||
candidate = str(ipaddress.IPv4Address(base + offset))
|
||||
if candidate not in taken_ips:
|
||||
return candidate
|
||||
|
||||
raise RuntimeError('Service IP pool exhausted (offsets 20-254 all taken)')
|
||||
|
||||
# ── Compose override ──────────────────────────────────────────────────
|
||||
|
||||
def _render_compose_override(self, installed_records: dict) -> str:
|
||||
"""Generate docker-compose YAML override for all installed services."""
|
||||
services: Dict[str, Any] = {}
|
||||
|
||||
for svc_id, record in installed_records.items():
|
||||
manifest = record.get('manifest', {})
|
||||
container_name = record.get('container_name', svc_id)
|
||||
image = manifest.get('image', record.get('image', ''))
|
||||
service_ip = record.get('service_ip', '')
|
||||
|
||||
# Volumes
|
||||
volumes = []
|
||||
for vol in manifest.get('volumes', []):
|
||||
vol_name = vol.get('name', '')
|
||||
mount = vol.get('mount', '')
|
||||
if vol_name and mount:
|
||||
volumes.append(f'{vol_name}:{mount}')
|
||||
|
||||
# Environment
|
||||
environment: Dict[str, str] = {}
|
||||
for env_entry in manifest.get('env', []):
|
||||
k = env_entry.get('key', '')
|
||||
v = str(env_entry.get('value', ''))
|
||||
if k:
|
||||
environment[k] = v
|
||||
|
||||
svc_def: Dict[str, Any] = {
|
||||
'image': image,
|
||||
'container_name': container_name,
|
||||
'restart': 'unless-stopped',
|
||||
'logging': {
|
||||
'driver': 'json-file',
|
||||
'options': {
|
||||
'max-size': '10m',
|
||||
'max-file': '5',
|
||||
},
|
||||
},
|
||||
'networks': {
|
||||
'cell-network': {
|
||||
'ipv4_address': service_ip,
|
||||
}
|
||||
},
|
||||
}
|
||||
if volumes:
|
||||
svc_def['volumes'] = volumes
|
||||
if environment:
|
||||
svc_def['environment'] = environment
|
||||
|
||||
services[container_name] = svc_def
|
||||
|
||||
# Collect named volumes
|
||||
named_volumes: Dict[str, Any] = {}
|
||||
for svc_id, record in installed_records.items():
|
||||
manifest = record.get('manifest', {})
|
||||
for vol in manifest.get('volumes', []):
|
||||
vol_name = vol.get('name', '')
|
||||
if vol_name:
|
||||
named_volumes[vol_name] = None # Docker default driver
|
||||
|
||||
doc: Dict[str, Any] = {
|
||||
'version': '3.8',
|
||||
'services': services,
|
||||
'networks': {
|
||||
'cell-network': {
|
||||
'external': True,
|
||||
}
|
||||
},
|
||||
}
|
||||
if named_volumes:
|
||||
doc['volumes'] = named_volumes
|
||||
|
||||
return yaml.dump(doc, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
def _write_compose_override(self, content: str) -> None:
|
||||
"""Atomic write of the compose override file."""
|
||||
tmp_path = self.compose_override + '.tmp'
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.compose_override)),
|
||||
exist_ok=True)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
with open(tmp_path, 'w') as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
try:
|
||||
os.fsync(f.fileno())
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp_path, self.compose_override)
|
||||
|
||||
# ── Index / manifest fetching ─────────────────────────────────────────
|
||||
|
||||
def fetch_index(self) -> list:
|
||||
"""Fetch and cache the service index."""
|
||||
import time
|
||||
now = time.time()
|
||||
if self._index_cache is not None and (now - self._index_cache_time) < self._cache_ttl:
|
||||
return self._index_cache
|
||||
try:
|
||||
resp = requests.get(self.index_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._index_cache = data if isinstance(data, list) else data.get('services', [])
|
||||
self._index_cache_time = now
|
||||
return self._index_cache
|
||||
except Exception as e:
|
||||
logger.warning(f'fetch_index failed: {e}')
|
||||
return self._index_cache or []
|
||||
|
||||
def _fetch_manifest(self, service_id: str) -> dict:
|
||||
"""Fetch a service manifest by ID."""
|
||||
url = MANIFEST_URL_TPL.format(id=service_id)
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── Core operations ───────────────────────────────────────────────────
|
||||
|
||||
def install(self, service_id: str) -> dict:
|
||||
"""Install a service from the store."""
|
||||
from firewall_manager import apply_service_rules
|
||||
|
||||
with self._lock:
|
||||
# Already installed?
|
||||
installed = self.config_manager.get_installed_services()
|
||||
if service_id in installed:
|
||||
return {'ok': True, 'already_installed': True}
|
||||
|
||||
# Fetch and validate manifest
|
||||
try:
|
||||
manifest = self._fetch_manifest(service_id)
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': f'Failed to fetch manifest: {e}'}
|
||||
|
||||
ok, errs = self._validate_manifest(manifest)
|
||||
if not ok:
|
||||
return {'ok': False, 'errors': errs}
|
||||
|
||||
# Allocate IP
|
||||
try:
|
||||
ip = self._allocate_service_ip(service_id)
|
||||
except RuntimeError as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
# Build install record
|
||||
record = {
|
||||
'id': service_id,
|
||||
'name': manifest.get('name', service_id),
|
||||
'container_name': manifest['container_name'],
|
||||
'image': manifest.get('image', ''),
|
||||
'service_ip': ip,
|
||||
'caddy_route': manifest.get('caddy_route'),
|
||||
'iptables_rules': manifest.get('iptables_rules', []),
|
||||
'manifest': manifest,
|
||||
'installed_at': datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Persist to config
|
||||
self.config_manager.set_installed_service(service_id, record)
|
||||
identity = self.config_manager.get_identity()
|
||||
service_ips = dict(identity.get('service_ips', {}))
|
||||
service_ips[service_id] = ip
|
||||
self.config_manager.set_identity_field('service_ips', service_ips)
|
||||
|
||||
# Write compose override
|
||||
all_installed = self.config_manager.get_installed_services()
|
||||
try:
|
||||
content = self._render_compose_override(all_installed)
|
||||
self._write_compose_override(content)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to write compose override: {e}')
|
||||
|
||||
# Apply iptables rules (best-effort)
|
||||
try:
|
||||
apply_service_rules(service_id, ip, manifest.get('iptables_rules', []))
|
||||
except Exception as e:
|
||||
logger.warning(f'apply_service_rules for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
# Regenerate Caddyfile
|
||||
try:
|
||||
caddy_routes = [
|
||||
r.get('caddy_route')
|
||||
for r in all_installed.values()
|
||||
if r.get('caddy_route')
|
||||
]
|
||||
self.caddy_manager.regenerate_with_installed(caddy_routes)
|
||||
except Exception as e:
|
||||
logger.warning(f'caddy regenerate for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
# Start the container via docker compose
|
||||
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'compose',
|
||||
'-f', base_compose,
|
||||
'-f', self.compose_override,
|
||||
'up', '-d', manifest['container_name']],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
f'docker compose up for {service_id} failed: {result.stderr.strip()}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'docker compose up for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'service_ip': ip,
|
||||
'container_name': manifest['container_name'],
|
||||
}
|
||||
|
||||
def remove(self, service_id: str, purge_data: bool = False) -> dict:
|
||||
"""Remove an installed service."""
|
||||
from firewall_manager import clear_service_rules
|
||||
|
||||
with self._lock:
|
||||
installed = self.config_manager.get_installed_services()
|
||||
record = installed.get(service_id)
|
||||
if not record:
|
||||
return {'ok': False, 'error': f'Service {service_id} is not installed'}
|
||||
|
||||
container_name = record.get('container_name', service_id)
|
||||
manifest = record.get('manifest', {})
|
||||
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
|
||||
|
||||
# Stop and remove container
|
||||
try:
|
||||
subprocess.run(
|
||||
['docker', 'compose',
|
||||
'-f', base_compose,
|
||||
'-f', self.compose_override,
|
||||
'stop', container_name],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'docker compose stop for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['docker', 'rm', '-f', container_name],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'docker rm for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
# Clear iptables rules
|
||||
try:
|
||||
clear_service_rules(service_id)
|
||||
except Exception as e:
|
||||
logger.warning(f'clear_service_rules for {service_id} failed (non-fatal): {e}')
|
||||
|
||||
# Remove from config, regenerate compose + caddy
|
||||
self.config_manager.remove_installed_service(service_id)
|
||||
remaining = self.config_manager.get_installed_services()
|
||||
|
||||
try:
|
||||
content = self._render_compose_override(remaining)
|
||||
self._write_compose_override(content)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to write compose override after remove: {e}')
|
||||
|
||||
try:
|
||||
caddy_routes = [
|
||||
r.get('caddy_route')
|
||||
for r in remaining.values()
|
||||
if r.get('caddy_route')
|
||||
]
|
||||
self.caddy_manager.regenerate_with_installed(caddy_routes)
|
||||
except Exception as e:
|
||||
logger.warning(f'caddy regenerate after remove failed (non-fatal): {e}')
|
||||
|
||||
# Purge named volumes if requested
|
||||
if purge_data:
|
||||
for vol in manifest.get('volumes', []):
|
||||
vol_name = vol.get('name', '')
|
||||
if vol_name:
|
||||
try:
|
||||
subprocess.run(
|
||||
['docker', 'volume', 'rm', vol_name],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'docker volume rm {vol_name} failed (non-fatal): {e}'
|
||||
)
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
def list_services(self) -> dict:
|
||||
"""Return available (from index) and installed services."""
|
||||
available = self.fetch_index()
|
||||
installed = self.config_manager.get_installed_services()
|
||||
return {'available': available, 'installed': installed}
|
||||
|
||||
def reapply_on_startup(self) -> None:
|
||||
"""Re-apply firewall and Caddy rules for all installed services on startup."""
|
||||
from firewall_manager import apply_service_rules
|
||||
|
||||
installed = self.config_manager.get_installed_services()
|
||||
if not installed:
|
||||
return
|
||||
|
||||
# Regenerate compose override in case it was deleted
|
||||
try:
|
||||
content = self._render_compose_override(installed)
|
||||
self._write_compose_override(content)
|
||||
except Exception as e:
|
||||
logger.warning(f'reapply_on_startup: compose override write failed: {e}')
|
||||
|
||||
# Re-apply iptables rules
|
||||
for svc_id, record in installed.items():
|
||||
ip = record.get('service_ip', '')
|
||||
rules = record.get('iptables_rules', [])
|
||||
try:
|
||||
apply_service_rules(svc_id, ip, rules)
|
||||
except Exception as e:
|
||||
logger.warning(f'reapply_on_startup: apply_service_rules({svc_id}) failed: {e}')
|
||||
|
||||
# Regenerate Caddyfile
|
||||
try:
|
||||
caddy_routes = [
|
||||
r.get('caddy_route')
|
||||
for r in installed.values()
|
||||
if r.get('caddy_route')
|
||||
]
|
||||
self.caddy_manager.regenerate_with_installed(caddy_routes)
|
||||
except Exception as e:
|
||||
logger.warning(f'reapply_on_startup: caddy regenerate failed: {e}')
|
||||
@@ -0,0 +1,6 @@
|
||||
version: '3.3'
|
||||
services: {}
|
||||
networks:
|
||||
cell-network:
|
||||
external: true
|
||||
name: pic_cell-network
|
||||
@@ -215,6 +215,7 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./.env:/app/.env.compose
|
||||
- ./docker-compose.yml:/app/docker-compose.yml:ro
|
||||
- ./docker-compose.services.yml:/app/docker-compose.services.yml
|
||||
- ./scripts:/app/scripts:ro
|
||||
pid: host
|
||||
restart: unless-stopped
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
||||
Wifi,
|
||||
Server,
|
||||
Key,
|
||||
Package,
|
||||
Package2,
|
||||
Settings as SettingsIcon,
|
||||
Link2,
|
||||
@@ -42,6 +43,7 @@ import Login from './pages/Login';
|
||||
import AccountSettings from './pages/AccountSettings';
|
||||
import PeerDashboard from './pages/PeerDashboard';
|
||||
import MyServices from './pages/MyServices';
|
||||
import Store from './pages/Store';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupGuard from './components/SetupGuard';
|
||||
|
||||
@@ -238,6 +240,7 @@ function AppCore() {
|
||||
{ name: 'Routing', href: '/routing', icon: Wifi },
|
||||
{ name: 'Vault', href: '/vault', icon: Key },
|
||||
{ name: 'Containers', href: '/containers', icon: Package2 },
|
||||
{ name: 'Store', href: '/store', icon: Package },
|
||||
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
||||
{ name: 'Logs', href: '/logs', icon: Activity },
|
||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
||||
@@ -343,6 +346,7 @@ function AppCore() {
|
||||
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
|
||||
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
|
||||
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
|
||||
<Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} />
|
||||
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
||||
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
||||
|
||||
@@ -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;
|
||||
@@ -288,6 +288,16 @@ export const cellLinkAPI = {
|
||||
getServices: () => api.get('/api/cells/services'),
|
||||
};
|
||||
|
||||
// Service Store API
|
||||
export const storeAPI = {
|
||||
listServices: () => api.get('/api/store/services'),
|
||||
getManifest: (id) => api.get(`/api/store/services/${id}/manifest`),
|
||||
installService: (id) => api.post(`/api/store/services/${id}/install`),
|
||||
removeService: (id, purge = false) => api.delete(`/api/store/services/${id}`, { params: { purge } }),
|
||||
listInstalled: () => api.get('/api/store/installed'),
|
||||
refreshIndex: () => api.post('/api/store/refresh'),
|
||||
};
|
||||
|
||||
// Health check
|
||||
export const healthAPI = {
|
||||
check: () => api.get('/health'),
|
||||
|
||||
Reference in New Issue
Block a user