replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 14s
Build and Push Docker Image / build (push) Failing after 14s
This commit is contained in:
+27
-2
@@ -57,7 +57,9 @@ API_PREFIX = f'/s/{API_KEY}' if API_KEY else ''
|
|||||||
# upstream by hash-lock/OIDC instead of the path-token.
|
# upstream by hash-lock/OIDC instead of the path-token.
|
||||||
DASHBOARD_PATHS = frozenset({
|
DASHBOARD_PATHS = frozenset({
|
||||||
'/', '/index.html', '/configure', '/health',
|
'/', '/index.html', '/configure', '/health',
|
||||||
'/api/stats', '/api/library', '/api/services', '/api/languages',
|
'/api/stats', '/api/library', '/api/services', '/api/neighbors', '/api/languages',
|
||||||
|
# The dashboard's shared nav element, loaded as a module from index.html.
|
||||||
|
'/meta-service-menu.js',
|
||||||
'/transcode/metrics',
|
'/transcode/metrics',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,6 +256,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if path == '/' or path == '/index.html':
|
if path == '/' or path == '/index.html':
|
||||||
return self.serve_setup_page()
|
return self.serve_setup_page()
|
||||||
|
|
||||||
|
# The one static asset under www/ that the dashboard loads as a module:
|
||||||
|
# the shared <meta-service-menu> element. There is no generic static
|
||||||
|
# route here (index.html is string-injected on every request), so this
|
||||||
|
# is an explicit allowlist of one rather than an open file server.
|
||||||
|
if path == '/meta-service-menu.js':
|
||||||
|
return self.serve_www_asset('meta-service-menu.js', 'text/javascript')
|
||||||
|
|
||||||
# Configure page
|
# Configure page
|
||||||
if path == '/configure':
|
if path == '/configure':
|
||||||
return self.serve_configure_page()
|
return self.serve_configure_page()
|
||||||
@@ -289,7 +298,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Discovered services (for inter-service navigation)
|
# Discovered services (for inter-service navigation)
|
||||||
if path == '/api/services':
|
if path == '/api/services' or path == '/api/neighbors':
|
||||||
return self.handle_services_api()
|
return self.handle_services_api()
|
||||||
|
|
||||||
# === File API ===
|
# === File API ===
|
||||||
@@ -370,6 +379,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
|
def serve_www_asset(self, name, content_type):
|
||||||
|
"""Serve one named file from www/ verbatim (no template injection)."""
|
||||||
|
for base in (os.path.dirname(__file__), os.path.dirname(os.path.dirname(__file__))):
|
||||||
|
candidate = os.path.join(base, 'www', name)
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
with open(candidate, 'rb') as f:
|
||||||
|
body = f.read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', content_type)
|
||||||
|
self.send_header('Content-Length', len(body))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
def serve_setup_page(self):
|
def serve_setup_page(self):
|
||||||
"""Serve the setup/dashboard page."""
|
"""Serve the setup/dashboard page."""
|
||||||
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
|
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
|
||||||
|
|||||||
@@ -13,15 +13,6 @@ from .provider import StorageProvider, VideoMetadata
|
|||||||
from .redis_storage import RedisStorage
|
from .redis_storage import RedisStorage
|
||||||
from .leader_storage import LeaderStorage
|
from .leader_storage import LeaderStorage
|
||||||
from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client
|
from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client
|
||||||
from .service_registration import (
|
|
||||||
ServiceRegistration,
|
|
||||||
ServiceInfo,
|
|
||||||
get_service_discovery, # Kept for backward compatibility
|
|
||||||
init_service_discovery # Kept for backward compatibility
|
|
||||||
)
|
|
||||||
|
|
||||||
# Backward compatibility alias
|
|
||||||
ServiceDiscovery = ServiceRegistration
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'StorageProvider',
|
'StorageProvider',
|
||||||
@@ -31,9 +22,4 @@ __all__ = [
|
|||||||
'LeaderClient',
|
'LeaderClient',
|
||||||
'LeaderLockInfo',
|
'LeaderLockInfo',
|
||||||
'get_leader_client',
|
'get_leader_client',
|
||||||
'ServiceRegistration',
|
|
||||||
'ServiceDiscovery', # Backward compatibility alias
|
|
||||||
'ServiceInfo',
|
|
||||||
'get_service_discovery',
|
|
||||||
'init_service_discovery',
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ def get_leader_client() -> Optional[LeaderClient]:
|
|||||||
_leader_client = LeaderClient(
|
_leader_client = LeaderClient(
|
||||||
meta_core_path=os.environ.get("META_CORE_PATH"),
|
meta_core_path=os.environ.get("META_CORE_PATH"),
|
||||||
meta_core_url=os.environ.get("META_CORE_URL"),
|
meta_core_url=os.environ.get("META_CORE_URL"),
|
||||||
base_url=os.environ.get("BASE_URL") or None,
|
# PUBLIC_URL (a browser-reachable debug-direct port) wins over
|
||||||
|
# BASE_URL (the Caddy URL), same ladder as meta-core. Announcing a
|
||||||
|
# Caddy URL on a stack with no Caddy puts a dead link in every menu.
|
||||||
|
base_url=os.environ.get("PUBLIC_URL") or os.environ.get("BASE_URL") or None,
|
||||||
)
|
)
|
||||||
return _leader_client
|
return _leader_client
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
"""
|
|
||||||
Service Registration for meta-stremio.
|
|
||||||
|
|
||||||
Python implementation of the service registration pattern used in meta-sort/meta-fuse.
|
|
||||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json.
|
|
||||||
|
|
||||||
Service discovery is centralized in meta-core - this module only handles registration.
|
|
||||||
For service discovery, use the /api/services endpoint (fetches from meta-core).
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Service registration with heartbeat
|
|
||||||
- Automatic heartbeat loop
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional, Dict, Callable
|
|
||||||
from urllib.request import urlopen
|
|
||||||
from urllib.error import URLError
|
|
||||||
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core')
|
|
||||||
SERVICE_NAME = os.environ.get('SERVICE_NAME', 'meta-stremio')
|
|
||||||
SERVICE_VERSION = os.environ.get('SERVICE_VERSION', '1.0.0')
|
|
||||||
BASE_URL = os.environ.get('BASE_URL', '')
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ServiceInfo:
|
|
||||||
"""
|
|
||||||
Service registration info stored in JSON file.
|
|
||||||
|
|
||||||
Simplified format matching TypeScript ServiceInfo interface:
|
|
||||||
- name: Service name (e.g., 'meta-stremio')
|
|
||||||
- hostname: Container/host hostname
|
|
||||||
- baseUrl: Base URL for the service (e.g., 'http://localhost:8182')
|
|
||||||
- status: Current status ('running' | 'stale' | 'stopped')
|
|
||||||
- lastHeartbeat: ISO timestamp of last heartbeat
|
|
||||||
- role: Optional role ('leader' | 'follower') - only used by meta-core
|
|
||||||
"""
|
|
||||||
name: str
|
|
||||||
hostname: str
|
|
||||||
baseUrl: str
|
|
||||||
status: str = 'running' # running | stale | stopped
|
|
||||||
lastHeartbeat: str = ''
|
|
||||||
role: Optional[str] = None # "leader", "follower", or None (for non-meta-core services)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""Convert to dictionary for JSON serialization.
|
|
||||||
Excludes None values to avoid polluting JSON with null fields.
|
|
||||||
"""
|
|
||||||
result = asdict(self)
|
|
||||||
# Remove None values (e.g., role should only appear for meta-core)
|
|
||||||
return {k: v for k, v in result.items() if v is not None}
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceRegistration:
|
|
||||||
"""
|
|
||||||
Service Discovery using shared filesystem.
|
|
||||||
|
|
||||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
|
||||||
and can discover other registered services.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
meta_core_path: str = None,
|
|
||||||
service_name: str = None,
|
|
||||||
version: str = None,
|
|
||||||
api_url: str = None,
|
|
||||||
base_url: str = None,
|
|
||||||
capabilities: List[str] = None,
|
|
||||||
endpoints: Dict[str, str] = None,
|
|
||||||
heartbeat_interval: float = 30.0,
|
|
||||||
stale_threshold: float = 60.0
|
|
||||||
):
|
|
||||||
self.meta_core_path = meta_core_path or META_CORE_PATH
|
|
||||||
self.service_name = service_name or SERVICE_NAME
|
|
||||||
# Use base_url if provided, then api_url, then env BASE_URL, then auto-detect
|
|
||||||
self.base_url = base_url or api_url or BASE_URL or self._get_default_api_url()
|
|
||||||
self.heartbeat_interval = heartbeat_interval
|
|
||||||
self.stale_threshold = stale_threshold
|
|
||||||
|
|
||||||
# Keep for backwards compatibility but not used in simplified format
|
|
||||||
self._version = version or SERVICE_VERSION
|
|
||||||
self._capabilities = capabilities or []
|
|
||||||
self._endpoints = endpoints or {}
|
|
||||||
|
|
||||||
self.current_hostname = socket.gethostname()
|
|
||||||
self.services_dir = os.path.join(self.meta_core_path, 'services')
|
|
||||||
# Use hostname-based file naming like TypeScript services
|
|
||||||
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}-{self.current_hostname}.json')
|
|
||||||
|
|
||||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._is_started = False
|
|
||||||
|
|
||||||
def _get_default_api_url(self) -> str:
|
|
||||||
"""Get default API URL using local IP and PORT."""
|
|
||||||
port = os.environ.get('PORT', '7000')
|
|
||||||
ip = self._get_local_ip()
|
|
||||||
return f'http://{ip}:{port}'
|
|
||||||
|
|
||||||
def _get_local_ip(self) -> str:
|
|
||||||
"""Get the local machine's IP address."""
|
|
||||||
try:
|
|
||||||
# Create a socket to determine the outbound IP
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
return ip
|
|
||||||
except Exception:
|
|
||||||
return socket.gethostname() or 'localhost'
|
|
||||||
|
|
||||||
def _ensure_services_dir(self) -> None:
|
|
||||||
"""Ensure services directory exists."""
|
|
||||||
Path(self.services_dir).mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def _build_service_info(self, status: str = 'running') -> ServiceInfo:
|
|
||||||
"""Build service info for this service (simplified format)."""
|
|
||||||
return ServiceInfo(
|
|
||||||
name=self.service_name,
|
|
||||||
hostname=self.current_hostname,
|
|
||||||
baseUrl=self.base_url,
|
|
||||||
status=status,
|
|
||||||
lastHeartbeat=datetime.utcnow().isoformat() + 'Z'
|
|
||||||
)
|
|
||||||
|
|
||||||
def register(self) -> None:
|
|
||||||
"""Register this service."""
|
|
||||||
self._ensure_services_dir()
|
|
||||||
|
|
||||||
info = self._build_service_info('starting')
|
|
||||||
|
|
||||||
with open(self.service_file_path, 'w') as f:
|
|
||||||
json.dump(info.to_dict(), f, indent=2)
|
|
||||||
|
|
||||||
print(f'[ServiceRegistration] Registered {self.service_name}')
|
|
||||||
|
|
||||||
def update_status(self, status: str) -> None:
|
|
||||||
"""Update service status."""
|
|
||||||
try:
|
|
||||||
with open(self.service_file_path, 'r') as f:
|
|
||||||
info = json.load(f)
|
|
||||||
|
|
||||||
info['status'] = status
|
|
||||||
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
with open(self.service_file_path, 'w') as f:
|
|
||||||
json.dump(info, f, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[ServiceRegistration] Failed to update status: {e}')
|
|
||||||
|
|
||||||
def heartbeat(self) -> None:
|
|
||||||
"""Send heartbeat (update lastHeartbeat timestamp)."""
|
|
||||||
try:
|
|
||||||
with open(self.service_file_path, 'r') as f:
|
|
||||||
info = json.load(f)
|
|
||||||
|
|
||||||
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
with open(self.service_file_path, 'w') as f:
|
|
||||||
json.dump(info, f, indent=2)
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
# If file was deleted, re-register
|
|
||||||
self.register()
|
|
||||||
self.update_status('running')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[ServiceRegistration] Heartbeat failed: {e}')
|
|
||||||
|
|
||||||
def _heartbeat_loop(self) -> None:
|
|
||||||
"""Background heartbeat loop."""
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
self.heartbeat()
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[ServiceRegistration] Heartbeat error: {e}')
|
|
||||||
|
|
||||||
self._stop_event.wait(self.heartbeat_interval)
|
|
||||||
|
|
||||||
def start_heartbeat(self) -> None:
|
|
||||||
"""Start heartbeat loop in background thread."""
|
|
||||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
|
||||||
return
|
|
||||||
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._heartbeat_thread = threading.Thread(
|
|
||||||
target=self._heartbeat_loop,
|
|
||||||
daemon=True,
|
|
||||||
name='service-heartbeat'
|
|
||||||
)
|
|
||||||
self._heartbeat_thread.start()
|
|
||||||
|
|
||||||
def stop_heartbeat(self) -> None:
|
|
||||||
"""Stop heartbeat loop."""
|
|
||||||
self._stop_event.set()
|
|
||||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
|
||||||
self._heartbeat_thread.join(timeout=2.0)
|
|
||||||
|
|
||||||
def unregister(self) -> None:
|
|
||||||
"""Unregister this service (on shutdown)."""
|
|
||||||
self.stop_heartbeat()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.update_status('stopped')
|
|
||||||
print(f'[ServiceRegistration] Unregistered {self.service_name}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[ServiceRegistration] Failed to unregister: {e}')
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Lifecycle
|
|
||||||
# ========================================================================
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""Full startup sequence."""
|
|
||||||
if self._is_started:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to register, but don't fail if filesystem is read-only
|
|
||||||
# This allows discovery-only mode when we can't write
|
|
||||||
can_register = os.access(self.services_dir, os.W_OK) if os.path.exists(self.services_dir) else False
|
|
||||||
|
|
||||||
if can_register:
|
|
||||||
try:
|
|
||||||
self.register()
|
|
||||||
self.update_status('running')
|
|
||||||
self.start_heartbeat()
|
|
||||||
print(f'[ServiceRegistration] Started {self.service_name}-{self.current_hostname} at {self.base_url}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[ServiceRegistration] Registration failed (read-only?): {e}')
|
|
||||||
print(f'[ServiceRegistration] Running in discovery-only mode')
|
|
||||||
else:
|
|
||||||
print(f'[ServiceRegistration] Services directory not writable, running in discovery-only mode')
|
|
||||||
|
|
||||||
self._is_started = True
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Full shutdown sequence."""
|
|
||||||
if not self._is_started:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.unregister()
|
|
||||||
self._is_started = False
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Getters
|
|
||||||
# ========================================================================
|
|
||||||
|
|
||||||
def get_service_file_path(self) -> str:
|
|
||||||
return self.service_file_path
|
|
||||||
|
|
||||||
def get_services_dir(self) -> str:
|
|
||||||
return self.services_dir
|
|
||||||
|
|
||||||
def is_started(self) -> bool:
|
|
||||||
return self._is_started
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
_service_registration: Optional[ServiceRegistration] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_service_discovery() -> ServiceRegistration:
|
|
||||||
"""Get or create the service registration singleton.
|
|
||||||
|
|
||||||
Note: Function name kept as 'get_service_discovery' for backward compatibility.
|
|
||||||
"""
|
|
||||||
global _service_registration
|
|
||||||
if _service_registration is None:
|
|
||||||
_service_registration = ServiceRegistration()
|
|
||||||
return _service_registration
|
|
||||||
|
|
||||||
|
|
||||||
def init_service_discovery(
|
|
||||||
api_url: str = None,
|
|
||||||
base_url: str = None,
|
|
||||||
**kwargs
|
|
||||||
) -> ServiceRegistration:
|
|
||||||
"""Initialize and start the service registration singleton.
|
|
||||||
|
|
||||||
Note: Function name kept as 'init_service_discovery' for backward compatibility.
|
|
||||||
"""
|
|
||||||
global _service_registration
|
|
||||||
_service_registration = ServiceRegistration(api_url=api_url, base_url=base_url, **kwargs)
|
|
||||||
_service_registration.start()
|
|
||||||
return _service_registration
|
|
||||||
+14
-69
@@ -292,6 +292,14 @@
|
|||||||
footer a:hover { text-decoration: underline; }
|
footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* Services Navigation */
|
/* Services Navigation */
|
||||||
|
/* <meta-service-menu> is in shadow DOM and cannot read the vars
|
||||||
|
above; map this UI's palette onto the four tokens it does read. */
|
||||||
|
meta-service-menu {
|
||||||
|
--mm-nav-fg: var(--text-primary);
|
||||||
|
--mm-nav-bg: var(--bg-tertiary);
|
||||||
|
--mm-nav-border: var(--border-color);
|
||||||
|
--mm-nav-accent: var(--accent-primary);
|
||||||
|
}
|
||||||
.services-nav {
|
.services-nav {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -362,16 +370,13 @@
|
|||||||
.services-nav { flex-direction: column; align-items: flex-start; }
|
.services-nav { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script type="module" src="meta-service-menu.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Services Navigation -->
|
<!-- Neighbouring meta-* services (meta-discovery v1). The element
|
||||||
<nav class="services-nav" id="servicesNav" style="display: none;">
|
fetches /api/neighbors from this origin and renders a burger menu. -->
|
||||||
<span class="services-nav-label">Services:</span>
|
<meta-service-menu id="servicesNav" current="meta-stremio"></meta-service-menu>
|
||||||
<div class="services-nav-items" id="servicesNavItems">
|
|
||||||
<!-- Populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div>
|
<div>
|
||||||
@@ -548,78 +553,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service icons mapping
|
// Service nav is <meta-service-menu> now — no local icon map,
|
||||||
const serviceIcons = {
|
// name formatter or poller lives here any more.
|
||||||
'meta-sort': '📁',
|
|
||||||
'meta-fuse': '🗂️',
|
|
||||||
'meta-stremio': '🎬',
|
|
||||||
'meta-core': '⚙️',
|
|
||||||
'meta-dup': '🔍',
|
|
||||||
'meta-orbit': '🌐',
|
|
||||||
'default': '📦'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch and display discovered services
|
|
||||||
async function fetchServices() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/services');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const navEl = document.getElementById('servicesNav');
|
|
||||||
const itemsEl = document.getElementById('servicesNavItems');
|
|
||||||
|
|
||||||
if (data.services && data.services.length > 0) {
|
|
||||||
// Sort services by name
|
|
||||||
const services = data.services.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
// Build navigation items
|
|
||||||
itemsEl.innerHTML = services.map(svc => {
|
|
||||||
const icon = serviceIcons[svc.name] || serviceIcons.default;
|
|
||||||
const isActive = svc.name === data.current;
|
|
||||||
const activeClass = isActive ? ' active' : '';
|
|
||||||
|
|
||||||
// Use external URL for other services, current path for self
|
|
||||||
const href = isActive ? '#' : svc.baseUrl;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<a href="${href}" class="service-link${activeClass}" onclick="event.preventDefault(); ${isActive ? '' : `window.location.href='${svc.baseUrl}'`}">
|
|
||||||
<span class="service-icon">${icon}</span>
|
|
||||||
<span>${formatServiceName(svc.name)}</span>
|
|
||||||
<span class="service-status"></span>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
navEl.style.display = 'flex';
|
|
||||||
} else {
|
|
||||||
navEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch services:', e);
|
|
||||||
document.getElementById('servicesNav').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatServiceName(name) {
|
|
||||||
// Convert 'meta-stremio' to 'Meta Stremio'
|
|
||||||
return name.split('-').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
fetchServices();
|
|
||||||
|
|
||||||
// Refresh metrics every 5 seconds
|
// Refresh metrics every 5 seconds
|
||||||
setInterval(fetchMetrics, 5000);
|
setInterval(fetchMetrics, 5000);
|
||||||
|
|
||||||
// Refresh stats every 30 seconds
|
// Refresh stats every 30 seconds
|
||||||
setInterval(fetchStats, 30000);
|
setInterval(fetchStats, 30000);
|
||||||
|
|
||||||
// Refresh services every 60 seconds
|
|
||||||
setInterval(fetchServices, 60000);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user