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)
|
# 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")
|
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
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Personal Internet Cell - Management Commands"
|
@echo "Personal Internet Cell - Management Commands"
|
||||||
@@ -93,12 +96,12 @@ init-peers:
|
|||||||
|
|
||||||
start:
|
start:
|
||||||
@echo "Starting Personal Internet Cell..."
|
@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'"
|
@echo "Services started. Check status with 'make status'"
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping Personal Internet Cell..."
|
@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."
|
@echo "Services stopped."
|
||||||
|
|
||||||
restart:
|
restart:
|
||||||
@@ -109,16 +112,16 @@ restart:
|
|||||||
status:
|
status:
|
||||||
@echo "Personal Internet Cell Status:"
|
@echo "Personal Internet Cell Status:"
|
||||||
@echo "================================"
|
@echo "================================"
|
||||||
$(DC) ps
|
$(DCF) ps
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "API Status:"
|
@echo "API Status:"
|
||||||
@curl -s http://localhost:3000/health || echo "API not responding"
|
@curl -s http://localhost:3000/health || echo "API not responding"
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(DC) logs -f
|
$(DCF) logs -f
|
||||||
|
|
||||||
logs-%:
|
logs-%:
|
||||||
$(DC) logs -f $*
|
$(DCF) logs -f $*
|
||||||
|
|
||||||
shell-%:
|
shell-%:
|
||||||
docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh
|
docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh
|
||||||
@@ -135,12 +138,12 @@ update:
|
|||||||
$(MAKE) setup; \
|
$(MAKE) setup; \
|
||||||
fi
|
fi
|
||||||
@echo "Rebuilding and restarting services..."
|
@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."
|
@echo "Update complete. Run 'make status' to verify."
|
||||||
|
|
||||||
reinstall:
|
reinstall:
|
||||||
@echo "Reinstalling Personal Internet Cell from scratch..."
|
@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/
|
@sudo rm -rf config/ data/
|
||||||
@$(MAKE) setup
|
@$(MAKE) setup
|
||||||
@$(MAKE) start
|
@$(MAKE) start
|
||||||
@@ -169,14 +172,14 @@ uninstall:
|
|||||||
case "$$ans" in \
|
case "$$ans" in \
|
||||||
y|Y) \
|
y|Y) \
|
||||||
echo "Stopping containers and removing images..."; \
|
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/..."; \
|
echo "Deleting config/ and data/..."; \
|
||||||
sudo rm -rf config/ data/; \
|
sudo rm -rf config/ data/; \
|
||||||
echo "Uninstall complete. Git repo and scripts remain."; \
|
echo "Uninstall complete. Git repo and scripts remain."; \
|
||||||
;; \
|
;; \
|
||||||
n|N|"") \
|
n|N|"") \
|
||||||
echo "Stopping and removing containers (keeping images and data)..."; \
|
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."; \
|
echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \
|
||||||
;; \
|
;; \
|
||||||
*) \
|
*) \
|
||||||
@@ -208,7 +211,7 @@ build-webui:
|
|||||||
|
|
||||||
start-core:
|
start-core:
|
||||||
@echo "Starting core services (caddy, dns, wireguard, api, webui)..."
|
@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."
|
@echo "Core services started. Run 'make start' to also bring up optional services."
|
||||||
|
|
||||||
start-dns:
|
start-dns:
|
||||||
|
|||||||
+9
-1
@@ -42,7 +42,7 @@ from managers import (
|
|||||||
routing_manager, vault_manager, container_manager,
|
routing_manager, vault_manager, container_manager,
|
||||||
cell_link_manager, auth_manager, setup_manager,
|
cell_link_manager, auth_manager, setup_manager,
|
||||||
caddy_manager,
|
caddy_manager,
|
||||||
ddns_manager,
|
ddns_manager, service_store_manager,
|
||||||
firewall_manager, EventType,
|
firewall_manager, EventType,
|
||||||
)
|
)
|
||||||
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
|
# 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()
|
sync_summary = cell_link_manager.replay_pending_pushes()
|
||||||
if sync_summary.get('attempted'):
|
if sync_summary.get('attempted'):
|
||||||
logger.info(f"Startup permission sync: {sync_summary}")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Startup enforcement failed (non-fatal): {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(_peer_dashboard_bp)
|
||||||
app.register_blueprint(_config_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
|
# Re-export config helpers so existing test imports/patches keep working
|
||||||
from routes.config import (
|
from routes.config import (
|
||||||
_set_pending_restart, _clear_pending_restart,
|
_set_pending_restart, _clear_pending_restart,
|
||||||
|
|||||||
@@ -373,6 +373,12 @@ class CaddyManager(BaseServiceManager):
|
|||||||
|
|
||||||
# ── certificate status ────────────────────────────────────────────────
|
# ── 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]:
|
def get_cert_status(self) -> Dict[str, Any]:
|
||||||
"""Return TLS cert status from identity['tls'] if present."""
|
"""Return TLS cert status from identity['tls'] if present."""
|
||||||
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
||||||
|
|||||||
@@ -474,6 +474,20 @@ class ConfigManager:
|
|||||||
self.configs['_identity'][key] = value
|
self.configs['_identity'][key] = value
|
||||||
self._save_all_configs()
|
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]:
|
def get_all_configs(self) -> Dict[str, Dict]:
|
||||||
"""Get all service configurations"""
|
"""Get all service configurations"""
|
||||||
return self.configs.copy()
|
return self.configs.copy()
|
||||||
|
|||||||
@@ -804,3 +804,52 @@ def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFI
|
|||||||
if ok:
|
if ok:
|
||||||
reload_coredns()
|
reload_coredns()
|
||||||
return ok
|
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)
|
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)
|
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 logger configuration
|
||||||
_service_log_configs = {
|
_service_log_configs = {
|
||||||
'network': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
'network': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
||||||
@@ -93,7 +102,7 @@ __all__ = [
|
|||||||
'email_manager', 'calendar_manager', 'file_manager',
|
'email_manager', 'calendar_manager', 'file_manager',
|
||||||
'routing_manager', 'vault_manager', 'container_manager',
|
'routing_manager', 'vault_manager', 'container_manager',
|
||||||
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
||||||
'ddns_manager',
|
'ddns_manager', 'service_store_manager',
|
||||||
'firewall_manager', 'EventType',
|
'firewall_manager', 'EventType',
|
||||||
'DATA_DIR', 'CONFIG_DIR',
|
'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
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./.env:/app/.env.compose
|
- ./.env:/app/.env.compose
|
||||||
- ./docker-compose.yml:/app/docker-compose.yml:ro
|
- ./docker-compose.yml:/app/docker-compose.yml:ro
|
||||||
|
- ./docker-compose.services.yml:/app/docker-compose.services.yml
|
||||||
- ./scripts:/app/scripts:ro
|
- ./scripts:/app/scripts:ro
|
||||||
pid: host
|
pid: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
|||||||
Wifi,
|
Wifi,
|
||||||
Server,
|
Server,
|
||||||
Key,
|
Key,
|
||||||
|
Package,
|
||||||
Package2,
|
Package2,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Link2,
|
Link2,
|
||||||
@@ -42,6 +43,7 @@ import Login from './pages/Login';
|
|||||||
import AccountSettings from './pages/AccountSettings';
|
import AccountSettings from './pages/AccountSettings';
|
||||||
import PeerDashboard from './pages/PeerDashboard';
|
import PeerDashboard from './pages/PeerDashboard';
|
||||||
import MyServices from './pages/MyServices';
|
import MyServices from './pages/MyServices';
|
||||||
|
import Store from './pages/Store';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupGuard from './components/SetupGuard';
|
import SetupGuard from './components/SetupGuard';
|
||||||
|
|
||||||
@@ -238,6 +240,7 @@ function AppCore() {
|
|||||||
{ name: 'Routing', href: '/routing', icon: Wifi },
|
{ name: 'Routing', href: '/routing', icon: Wifi },
|
||||||
{ name: 'Vault', href: '/vault', icon: Key },
|
{ name: 'Vault', href: '/vault', icon: Key },
|
||||||
{ name: 'Containers', href: '/containers', icon: Package2 },
|
{ name: 'Containers', href: '/containers', icon: Package2 },
|
||||||
|
{ name: 'Store', href: '/store', icon: Package },
|
||||||
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
||||||
{ name: 'Logs', href: '/logs', icon: Activity },
|
{ name: 'Logs', href: '/logs', icon: Activity },
|
||||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
||||||
@@ -343,6 +346,7 @@ function AppCore() {
|
|||||||
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
|
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
|
||||||
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
|
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
|
||||||
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></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="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
||||||
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
||||||
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></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'),
|
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
|
// Health check
|
||||||
export const healthAPI = {
|
export const healthAPI = {
|
||||||
check: () => api.get('/health'),
|
check: () => api.get('/health'),
|
||||||
|
|||||||
Reference in New Issue
Block a user