feat(phase3): PWA, auto-discovery, bookmarklet, multi-tab sync
- PWA: manifest, service worker (cache-first static, network-first API), offline page, install prompt banner - Auto-discovery: Docker socket + Traefik API scanning, approval UI - Quick-add bookmarklet: popup-based add page, favicon auto-detect - Multi-tab sync: BroadcastChannel for theme + data changes - i18n translations for all new strings (EN/RU)
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { findAll as findAllApps } from './appService.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DiscoveredService {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly source: 'docker' | 'traefik';
|
||||
readonly icon?: string;
|
||||
readonly description?: string;
|
||||
readonly alreadyRegistered: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveryConfig {
|
||||
readonly dockerSocketPath?: string;
|
||||
readonly traefikApiUrl?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
readonly services: readonly DiscoveredService[];
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
// --- Docker types ---
|
||||
|
||||
interface DockerContainer {
|
||||
readonly Id: string;
|
||||
readonly Names: readonly string[];
|
||||
readonly Image: string;
|
||||
readonly Ports: readonly DockerPort[];
|
||||
readonly Labels: Record<string, string>;
|
||||
readonly State: string;
|
||||
}
|
||||
|
||||
interface DockerPort {
|
||||
readonly IP?: string;
|
||||
readonly PrivatePort: number;
|
||||
readonly PublicPort?: number;
|
||||
readonly Type: string;
|
||||
}
|
||||
|
||||
// --- Traefik types ---
|
||||
|
||||
interface TraefikRouter {
|
||||
readonly name: string;
|
||||
readonly rule: string;
|
||||
readonly service: string;
|
||||
readonly entryPoints?: readonly string[];
|
||||
readonly status?: string;
|
||||
}
|
||||
|
||||
interface TraefikService {
|
||||
readonly name: string;
|
||||
readonly loadBalancer?: {
|
||||
readonly servers?: readonly { readonly url: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
// --- Docker Discovery ---
|
||||
|
||||
function extractUrlFromDockerLabels(labels: Record<string, string>): string | null {
|
||||
// Check for Traefik Host rule in labels
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (key.match(/traefik\.http\.routers\..+\.rule/)) {
|
||||
const hostMatch = value.match(/Host\(`([^`]+)`\)/);
|
||||
if (hostMatch) {
|
||||
const scheme = labels[key.replace('.rule', '.entrypoints')]?.includes('websecure')
|
||||
? 'https'
|
||||
: 'http';
|
||||
return `${scheme}://${hostMatch[1]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNameFromContainer(container: DockerContainer): string {
|
||||
const rawName = container.Names[0] ?? container.Id.slice(0, 12);
|
||||
// Docker container names start with /
|
||||
return rawName.replace(/^\//, '');
|
||||
}
|
||||
|
||||
function buildUrlFromPorts(ports: readonly DockerPort[]): string | null {
|
||||
const publicPort = ports.find((p) => p.PublicPort && p.Type === 'tcp');
|
||||
if (publicPort?.PublicPort) {
|
||||
const host = publicPort.IP && publicPort.IP !== '0.0.0.0' ? publicPort.IP : 'localhost';
|
||||
return `http://${host}:${publicPort.PublicPort}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function discoverDocker(socketPath: string): Promise<{
|
||||
readonly services: readonly DiscoveredService[];
|
||||
readonly error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Use curl with Unix socket to query Docker API
|
||||
const { stdout } = await execAsync(
|
||||
`curl -s --unix-socket "${socketPath}" http://localhost/containers/json?all=false`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
const containers: DockerContainer[] = JSON.parse(stdout);
|
||||
|
||||
const services: DiscoveredService[] = [];
|
||||
|
||||
for (const container of containers) {
|
||||
const name = extractNameFromContainer(container);
|
||||
const labelUrl = extractUrlFromDockerLabels(container.Labels);
|
||||
const portUrl = buildUrlFromPorts(container.Ports);
|
||||
const url = labelUrl ?? portUrl;
|
||||
|
||||
if (!url) {
|
||||
continue; // Skip containers without accessible URLs
|
||||
}
|
||||
|
||||
const description = container.Labels['org.opencontainers.image.description']
|
||||
?? `Docker container: ${container.Image}`;
|
||||
|
||||
services.push({
|
||||
name,
|
||||
url,
|
||||
source: 'docker',
|
||||
icon: container.Labels['org.opencontainers.image.title']?.toLowerCase(),
|
||||
description,
|
||||
alreadyRegistered: false // Will be resolved in discoverAll
|
||||
});
|
||||
}
|
||||
|
||||
return { services };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Docker discovery failed';
|
||||
return { services: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Traefik Discovery ---
|
||||
|
||||
function extractHostFromRule(rule: string): string | null {
|
||||
const hostMatch = rule.match(/Host\(`([^`]+)`\)/);
|
||||
return hostMatch ? hostMatch[1] : null;
|
||||
}
|
||||
|
||||
export async function discoverTraefik(apiUrl: string): Promise<{
|
||||
readonly services: readonly DiscoveredService[];
|
||||
readonly error?: string;
|
||||
}> {
|
||||
try {
|
||||
const normalizedUrl = apiUrl.replace(/\/+$/, '');
|
||||
|
||||
const [routersRes, servicesRes] = await Promise.all([
|
||||
fetch(`${normalizedUrl}/api/http/routers`),
|
||||
fetch(`${normalizedUrl}/api/http/services`)
|
||||
]);
|
||||
|
||||
if (!routersRes.ok) {
|
||||
return {
|
||||
services: [],
|
||||
error: `Traefik routers API returned ${routersRes.status}`
|
||||
};
|
||||
}
|
||||
|
||||
const routers: TraefikRouter[] = await routersRes.json();
|
||||
const traefikServices: TraefikService[] = servicesRes.ok ? await servicesRes.json() : [];
|
||||
|
||||
// Build a map of service name -> backend URL
|
||||
const serviceUrlMap = new Map<string, string>();
|
||||
for (const svc of traefikServices) {
|
||||
const backendUrl = svc.loadBalancer?.servers?.[0]?.url;
|
||||
if (backendUrl) {
|
||||
serviceUrlMap.set(svc.name, backendUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const services: DiscoveredService[] = [];
|
||||
|
||||
for (const router of routers) {
|
||||
const host = extractHostFromRule(router.rule);
|
||||
if (!host) continue;
|
||||
|
||||
const isSecure = router.entryPoints?.some(
|
||||
(ep) => ep === 'websecure' || ep === 'https'
|
||||
);
|
||||
const frontendUrl = `${isSecure ? 'https' : 'http'}://${host}`;
|
||||
|
||||
// Derive a clean name from the router name (strip @provider suffix)
|
||||
const name = router.name.replace(/@.*$/, '');
|
||||
|
||||
services.push({
|
||||
name,
|
||||
url: frontendUrl,
|
||||
source: 'traefik',
|
||||
description: serviceUrlMap.get(router.service)
|
||||
? `Backend: ${serviceUrlMap.get(router.service)}`
|
||||
: undefined,
|
||||
alreadyRegistered: false // Will be resolved in discoverAll
|
||||
});
|
||||
}
|
||||
|
||||
return { services };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Traefik discovery failed';
|
||||
return { services: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Combined Discovery ---
|
||||
|
||||
export async function discoverAll(config: DiscoveryConfig): Promise<DiscoveryResult> {
|
||||
const errors: string[] = [];
|
||||
const allServices: DiscoveredService[] = [];
|
||||
|
||||
// Run discovery in parallel
|
||||
const [dockerResult, traefikResult] = await Promise.all([
|
||||
config.dockerSocketPath
|
||||
? discoverDocker(config.dockerSocketPath)
|
||||
: Promise.resolve({ services: [] as DiscoveredService[] }),
|
||||
config.traefikApiUrl
|
||||
? discoverTraefik(config.traefikApiUrl)
|
||||
: Promise.resolve({ services: [] as DiscoveredService[] })
|
||||
]);
|
||||
|
||||
if ('error' in dockerResult && dockerResult.error) {
|
||||
errors.push(`Docker: ${dockerResult.error}`);
|
||||
}
|
||||
allServices.push(...dockerResult.services);
|
||||
|
||||
if ('error' in traefikResult && traefikResult.error) {
|
||||
errors.push(`Traefik: ${traefikResult.error}`);
|
||||
}
|
||||
allServices.push(...traefikResult.services);
|
||||
|
||||
// Deduplicate by URL (prefer Traefik entries since they have frontend URLs)
|
||||
const urlMap = new Map<string, DiscoveredService>();
|
||||
for (const service of allServices) {
|
||||
const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase();
|
||||
const existing = urlMap.get(normalizedUrl);
|
||||
if (!existing || (service.source === 'traefik' && existing.source === 'docker')) {
|
||||
urlMap.set(normalizedUrl, service);
|
||||
}
|
||||
}
|
||||
|
||||
// Check which services are already registered as apps
|
||||
const existingApps = await findAllApps();
|
||||
const existingUrls = new Set(
|
||||
existingApps.map((app) => app.url.replace(/\/+$/, '').toLowerCase())
|
||||
);
|
||||
|
||||
const services = Array.from(urlMap.values()).map((service) => {
|
||||
const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase();
|
||||
return {
|
||||
...service,
|
||||
alreadyRegistered: existingUrls.has(normalizedUrl)
|
||||
};
|
||||
});
|
||||
|
||||
return { services, errors };
|
||||
}
|
||||
Reference in New Issue
Block a user