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:
2026-03-25 00:59:19 +03:00
parent c6a7de895d
commit dd6958b4d6
28 changed files with 1712 additions and 266 deletions
+262
View File
@@ -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 };
}