diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts new file mode 100644 index 0000000..355064d --- /dev/null +++ b/src/app/api/discover/route.ts @@ -0,0 +1,248 @@ +import { NextResponse } from 'next/server'; +import type { DiscoveredService, ServiceCategory } from '@/lib/services'; +import { lookupService, lookupDatabase } from '@/lib/service-registry'; + +const COOLIFY_API = 'http://192.168.1.3:8000/api/v1'; +const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN || ''; +const SERVER_UUID = 'qk84w0goo4w48g4ggsoo0oss'; +const NUC_HOST = '192.168.1.3'; + +interface CoolifyResource { + id: number; + uuid: string; + name: string; + type: string; + status: string; + created_at: string; + updated_at: string; +} + +interface CoolifyAppDetail { + uuid: string; + name: string; + fqdn: string | null; + ports_exposes: string | null; + ports_mappings: string | null; + status: string; + description: string | null; +} + +interface CoolifyServiceDetail { + uuid: string; + name: string; + applications?: Array<{ + name: string; + human_name: string | null; + fqdn: string | null; + ports: string | null; + status: string; + image: string | null; + }>; + databases?: Array<{ + name: string; + status: string; + }>; +} + +function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' { + if (status.startsWith('running')) return 'running'; + if (status.startsWith('exited') || status === 'stopped') return 'stopped'; + return 'unknown'; +} + +function extractPort(fqdn: string | null, portsExposes: string | null, portsMappings: string | null): number { + // Try to extract port from FQDN (e.g., http://service.nuc.lan:3000) + if (fqdn) { + try { + const url = new URL(fqdn); + if (url.port) return parseInt(url.port, 10); + } catch { /* ignore */ } + } + + // Try ports_mappings first (host:container format like "3030:3000") + if (portsMappings) { + const first = portsMappings.split(',')[0].trim(); + const hostPort = first.split(':')[0]; + if (hostPort) return parseInt(hostPort, 10); + } + + // Fall back to ports_exposes (container port) + if (portsExposes) { + const first = portsExposes.split(',')[0].trim(); + return parseInt(first, 10); + } + + return 0; +} + +function extractPortFromServicePorts(ports: string | null): number { + if (!ports) return 0; + // Service ports can be "22222:22" or "3030:3000" + const first = ports.split(',')[0].trim(); + const parts = first.split(':'); + return parseInt(parts[0], 10) || 0; +} + +function cleanServiceName(name: string): string { + // Remove Coolify-style suffixes like "-ho0cwgcwos88cwc48g84c0g8" + return name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, ''); +} + +function buildUrl(fqdn: string | null, port: number): string { + if (fqdn) { + // Use the FQDN as-is if it looks like a proper URL + try { + const url = new URL(fqdn); + // If it's an sslip.io address, replace with nuc.lan + if (url.hostname.includes('sslip.io')) { + return `http://${NUC_HOST}:${port || url.port || 80}`; + } + return fqdn; + } catch { /* fall through */ } + } + if (port > 0) { + return `http://${NUC_HOST}:${port}`; + } + return `http://${NUC_HOST}`; +} + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${COOLIFY_TOKEN}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + return await res.json() as T; + } catch { + return null; + } +} + +export async function GET() { + try { + // Step 1: Get all resources from the server + const resources = await fetchJson( + `${COOLIFY_API}/servers/${SERVER_UUID}/resources` + ); + + if (!resources) { + return NextResponse.json( + { error: 'Failed to fetch resources from Coolify', services: [] }, + { status: 502, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } } + ); + } + + // Step 2: Fetch details for each resource type in parallel + const detailPromises = resources.map(async (resource): Promise => { + const healthStatus = mapCoolifyStatus(resource.status); + + if (resource.type === 'application') { + const detail = await fetchJson( + `${COOLIFY_API}/applications/${resource.uuid}` + ); + if (!detail) return null; + + const port = extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings); + const meta = lookupService(detail.name); + const url = buildUrl(detail.fqdn, port); + + return { + name: detail.name, + url, + port, + icon: meta.icon, + category: meta.category as ServiceCategory, + description: detail.description || meta.description, + source: 'discovered', + fqdn: detail.fqdn || undefined, + resourceType: 'application', + uuid: resource.uuid, + coolifyStatus: resource.status, + }; + } + + if (resource.type === 'service') { + const detail = await fetchJson( + `${COOLIFY_API}/services/${resource.uuid}` + ); + if (!detail) return null; + + // A Coolify "service" can contain multiple applications. Use the primary one. + const app = detail.applications?.[0]; + const cleanName = cleanServiceName(resource.name); + const meta = lookupService(cleanName); + + let port = 0; + let fqdn: string | undefined; + + if (app) { + port = extractPortFromServicePorts(app.ports) || extractPort(app.fqdn, null, null); + fqdn = app.fqdn && !app.fqdn.includes('sslip.io') ? app.fqdn : undefined; + } + + const url = buildUrl(fqdn || null, port); + + return { + name: cleanName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), + url, + port, + icon: meta.icon, + category: meta.category as ServiceCategory, + description: meta.description, + source: 'discovered', + fqdn, + resourceType: 'service', + uuid: resource.uuid, + coolifyStatus: resource.status, + }; + } + + if (resource.type.startsWith('standalone-')) { + const meta = lookupDatabase(resource.type, resource.name); + return { + name: resource.name, + url: `http://${NUC_HOST}:8000`, + port: 0, + icon: meta.icon, + category: meta.category as ServiceCategory, + description: meta.description, + source: 'discovered', + resourceType: 'database', + uuid: resource.uuid, + coolifyStatus: resource.status, + }; + } + + return null; + }); + + const results = await Promise.allSettled(detailPromises); + const discovered: DiscoveredService[] = []; + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + discovered.push(result.value); + } + } + + // Sort: running first, then by name + discovered.sort((a, b) => { + const aRunning = a.coolifyStatus.startsWith('running') ? 0 : 1; + const bRunning = b.coolifyStatus.startsWith('running') ? 0 : 1; + if (aRunning !== bRunning) return aRunning - bRunning; + return a.name.localeCompare(b.name); + }); + + return NextResponse.json( + { services: discovered }, + { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } } + ); + } catch (error) { + console.error('Discovery error:', error); + return NextResponse.json( + { error: 'Discovery failed', services: [] }, + { status: 500, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index bb1f6f2..00d8b97 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -45,6 +45,9 @@ export default function Home() { refreshDeployments, activeTab, setActiveTab, + discoveredServices, + discoveryLoading, + discoveryError, } = usePortal(); // Group services by category @@ -73,6 +76,9 @@ export default function Home() { const runningCount = services.filter(s => healthStatus[s.name] === 'running').length; const totalServices = services.length; + // Discovery source label + const isDiscovered = discoveredServices.length > 0; + const renderTabContent = () => { switch (activeTab) { case 'services': @@ -91,6 +97,21 @@ export default function Home() { {runningCount} of {totalServices} services running + {isDiscovered && ( + + Auto-discovered + + )} + {discoveryLoading && !isDiscovered && ( + + Discovering services... + + )} + {discoveryError && !isDiscovered && ( + + Using static list (Coolify unavailable) + + )} {/* No results message */} @@ -299,6 +320,12 @@ export default function Home() { Services {totalServices} +
+ Discovery + + {isDiscovered ? 'Coolify API' : 'Static'} + +
Bookmarks {filteredBookmarks.length} diff --git a/src/components/ServiceCard.tsx b/src/components/ServiceCard.tsx index 6f62b2a..da8c7f5 100644 --- a/src/components/ServiceCard.tsx +++ b/src/components/ServiceCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Service } from '@/lib/services'; +import { Service, DiscoveredService } from '@/lib/services'; import { HealthStatus } from '@/lib/PortalContext'; import { Icon } from './Icons'; @@ -23,7 +23,36 @@ const statusLabels: Record = { loading: 'Checking...', }; +function isDiscovered(service: Service): service is DiscoveredService { + return 'source' in service && (service as DiscoveredService).source === 'discovered'; +} + +function getFqdnLabel(service: Service): string | null { + if (!isDiscovered(service) || !service.fqdn) return null; + try { + const url = new URL(service.fqdn); + const hostname = url.hostname; + // Show just the subdomain part if it's a .nuc.lan address + if (hostname.endsWith('.nuc.lan')) { + return hostname.replace('.nuc.lan', ''); + } + return hostname; + } catch { + return null; + } +} + +function getResourceBadge(service: Service): string | null { + if (!isDiscovered(service)) return null; + if (service.resourceType === 'database') return 'DB'; + if (service.resourceType === 'application') return 'App'; + return null; +} + export function ServiceCard({ service, status }: ServiceCardProps) { + const fqdnLabel = getFqdnLabel(service); + const resourceBadge = getResourceBadge(service); + return ( {/* Status indicator */}
+ {resourceBadge && ( + + {resourceBadge} + + )} )} - {/* Port badge */} -
- :{service.port} + {/* Badge: FQDN subdomain or port */} +
+ {fqdnLabel && ( + + {fqdnLabel} + + )} + {service.port > 0 && ( + + :{service.port} + + )}
); diff --git a/src/lib/PortalContext.tsx b/src/lib/PortalContext.tsx index f95a75e..bd97a14 100644 --- a/src/lib/PortalContext.tsx +++ b/src/lib/PortalContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; -import { services, bookmarks, Service, Bookmark } from './services'; +import { services, bookmarks, Service, Bookmark, DiscoveredService, fallbackServices } from './services'; import type { Deployment } from './deployments'; export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading'; @@ -28,6 +28,11 @@ interface PortalContextType { refreshDeployments: () => Promise; activeTab: string; setActiveTab: (tab: string) => void; + // Discovery + discoveredServices: DiscoveredService[]; + discoveryLoading: boolean; + discoveryError: boolean; + refreshDiscover: () => Promise; } const PortalContext = createContext(undefined); @@ -48,6 +53,11 @@ export function PortalProvider({ children }: { children: ReactNode }) { const [deploymentsLoading, setDeploymentsLoading] = useState(false); const [activeTab, setActiveTab] = useState('whyrating'); + // Discovery state + const [discoveredServices, setDiscoveredServices] = useState([]); + const [discoveryLoading, setDiscoveryLoading] = useState(true); + const [discoveryError, setDiscoveryError] = useState(false); + // Apply dark mode to document useEffect(() => { if (darkMode) { @@ -69,7 +79,7 @@ export function PortalProvider({ children }: { children: ReactNode }) { localStorage.setItem('portal-dark-mode', String(darkMode)); }, [darkMode]); - // Fetch health status + // Fetch health status (used as fallback when discovery is unavailable) const refreshHealth = useCallback(async () => { setIsRefreshing(true); try { @@ -85,12 +95,57 @@ export function PortalProvider({ children }: { children: ReactNode }) { } }, []); - // Initial health fetch and periodic refresh + // Fetch discovered services from Coolify + const refreshDiscover = useCallback(async () => { + try { + const response = await fetch('/api/discover'); + if (response.ok) { + const data = await response.json(); + if (data.services && data.services.length > 0) { + setDiscoveredServices(data.services); + setDiscoveryError(false); + + // Build health status from discovered services + const newHealth: HealthState = {}; + for (const svc of data.services as DiscoveredService[]) { + if (svc.coolifyStatus.startsWith('running')) { + newHealth[svc.name] = 'running'; + } else if (svc.coolifyStatus.startsWith('exited') || svc.coolifyStatus === 'stopped') { + newHealth[svc.name] = 'stopped'; + } else { + newHealth[svc.name] = 'unknown'; + } + } + setHealthStatus(prev => ({ ...prev, ...newHealth })); + } else { + setDiscoveryError(true); + } + } else { + setDiscoveryError(true); + } + } catch (error) { + console.error('Failed to discover services:', error); + setDiscoveryError(true); + } finally { + setDiscoveryLoading(false); + } + }, []); + + // Initial discovery + periodic refresh (every 30s) useEffect(() => { - refreshHealth(); - const interval = setInterval(refreshHealth, 30000); // Refresh every 30 seconds + refreshDiscover(); + const interval = setInterval(refreshDiscover, 30000); return () => clearInterval(interval); - }, [refreshHealth]); + }, [refreshDiscover]); + + // Fall back to health checks if discovery fails + useEffect(() => { + if (discoveryError && discoveredServices.length === 0) { + refreshHealth(); + const interval = setInterval(refreshHealth, 30000); + return () => clearInterval(interval); + } + }, [discoveryError, discoveredServices.length, refreshHealth]); // Fetch deployments const refreshDeployments = useCallback(async () => { @@ -117,8 +172,13 @@ export function PortalProvider({ children }: { children: ReactNode }) { } }, [activeTab, refreshDeployments]); + // Determine which services to show: discovered or fallback + const activeServices: Service[] = discoveredServices.length > 0 + ? discoveredServices + : fallbackServices; + // Filter services and bookmarks based on search query - const filteredServices = services.filter(service => { + const filteredServices = activeServices.filter(service => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); return ( @@ -141,7 +201,7 @@ export function PortalProvider({ children }: { children: ReactNode }) { return ( {children} diff --git a/src/lib/service-registry.ts b/src/lib/service-registry.ts new file mode 100644 index 0000000..b285f8d --- /dev/null +++ b/src/lib/service-registry.ts @@ -0,0 +1,120 @@ +import type { ServiceCategory } from './services'; + +export interface ServiceMeta { + icon: string; + category: ServiceCategory; + description: string; +} + +// Map known service names/types to icons, categories, and descriptions. +// Keys are matched case-insensitively against the resource name from Coolify. +const registry: Record = { + // Infrastructure + 'coolify': { icon: 'server', category: 'infrastructure', description: 'Container deployment & management' }, + 'dozzle': { icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' }, + 'traefik': { icon: 'globe', category: 'infrastructure', description: 'Reverse proxy & load balancer' }, + 'tailscale': { icon: 'globe', category: 'infrastructure', description: 'Mesh VPN & secure access' }, + 'cloudflared': { icon: 'globe', category: 'infrastructure', description: 'Cloudflare tunnel' }, + 'crowdsec': { icon: 'shield', category: 'infrastructure', description: 'Collaborative security engine' }, + 'homepage': { icon: 'layout', category: 'infrastructure', description: 'Dashboard & start page' }, + 'nuc portal': { icon: 'monitor', category: 'infrastructure', description: 'NUC server dashboard' }, + 'playwriter': { icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' }, + + // Automation + 'n8n': { icon: 'workflow', category: 'automation', description: 'Workflow automation platform' }, + + // Development + 'gitea': { icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' }, + 'cloudbeaver': { icon: 'database', category: 'development', description: 'Database management UI' }, + 'adminer': { icon: 'table', category: 'development', description: 'Lightweight database admin' }, + + // Knowledge + 'outline': { icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' }, + 'getoutline': { icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' }, + 'nocodb': { icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' }, + 'knosia': { icon: 'book', category: 'knowledge', description: 'Knowledge base' }, + + // Storage + 'filebrowser': { icon: 'folder', category: 'storage', description: 'Web file manager' }, + 'minio': { icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' }, + 'kopia': { icon: 'archive', category: 'storage', description: 'Backup & restore' }, + 'palmr': { icon: 'folder', category: 'storage', description: 'File sharing platform' }, + + // Monitoring + 'uptime kuma': { icon: 'activity', category: 'monitoring', description: 'Service status monitoring' }, + 'uptime-kuma': { icon: 'activity', category: 'monitoring', description: 'Service status monitoring' }, + 'ntfy': { icon: 'bell', category: 'monitoring', description: 'Push notifications server' }, + 'monitoring stack': { icon: 'activity', category: 'monitoring', description: 'Prometheus & Grafana monitoring' }, + 'grafana': { icon: 'activity', category: 'monitoring', description: 'Metrics dashboard' }, + 'prometheus': { icon: 'activity', category: 'monitoring', description: 'Metrics collection' }, + + // Security + 'vaultwarden': { icon: 'lock', category: 'security', description: 'Password manager' }, + 'authentik': { icon: 'shield', category: 'security', description: 'Identity provider & SSO' }, + + // Mail + 'stalwart': { icon: 'bell', category: 'automation', description: 'Email server' }, + 'stalwart mail': { icon: 'bell', category: 'automation', description: 'Email server' }, + 'snappymail': { icon: 'bell', category: 'automation', description: 'Webmail client' }, + + // Databases + 'postgresql': { icon: 'database', category: 'development', description: 'PostgreSQL database' }, + 'postgres': { icon: 'database', category: 'development', description: 'PostgreSQL database' }, + 'mysql': { icon: 'database', category: 'development', description: 'MySQL database' }, + 'redis': { icon: 'database', category: 'development', description: 'Redis cache' }, + 'mariadb': { icon: 'database', category: 'development', description: 'MariaDB database' }, + + // Apps + 'googlescraper': { icon: 'search', category: 'automation', description: 'Google scraper API' }, + 'actionkit landing': { icon: 'layout', category: 'development', description: 'Landing page builder' }, +}; + +const defaultMeta: ServiceMeta = { + icon: 'box', + category: 'infrastructure', + description: '', +}; + +/** + * Look up metadata for a service by its name. + * Tries exact match first, then substring matching against registry keys. + */ +export function lookupService(name: string): ServiceMeta { + const lower = name.toLowerCase(); + + // Exact match + if (registry[lower]) { + return registry[lower]; + } + + // Try matching a registry key as a substring of the name + for (const [key, meta] of Object.entries(registry)) { + if (lower.includes(key)) { + return meta; + } + } + + // Infer category from resource type for databases + return defaultMeta; +} + +/** + * Get metadata for a database resource based on its type string from Coolify. + */ +export function lookupDatabase(type: string, name: string): ServiceMeta { + const lower = type.toLowerCase(); + if (lower.includes('postgresql') || lower.includes('postgres')) { + return { icon: 'database', category: 'development', description: 'PostgreSQL database' }; + } + if (lower.includes('mysql') || lower.includes('mariadb')) { + return { icon: 'database', category: 'development', description: 'MySQL database' }; + } + if (lower.includes('redis')) { + return { icon: 'database', category: 'development', description: 'Redis cache' }; + } + if (lower.includes('mongo')) { + return { icon: 'database', category: 'development', description: 'MongoDB database' }; + } + // Fall back to name-based lookup + return lookupService(name); +} diff --git a/src/lib/services.ts b/src/lib/services.ts index 06de4a1..3ac1069 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -11,6 +11,14 @@ export interface Service { container?: string; } +export interface DiscoveredService extends Service { + source: 'discovered' | 'static'; + fqdn?: string; + resourceType: 'application' | 'service' | 'database'; + uuid: string; + coolifyStatus: string; +} + export interface Bookmark { name: string; url: string; @@ -19,7 +27,7 @@ export interface Bookmark { description?: string; } -export const services: Service[] = [ +export const fallbackServices: Service[] = [ // Infrastructure { name: 'Coolify', url: 'http://192.168.1.3:8000', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' }, { name: 'Dozzle', url: 'http://192.168.1.3:9999', port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' }, @@ -51,6 +59,9 @@ export const services: Service[] = [ { name: 'Authentik', url: 'http://192.168.1.3:9090', port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' }, ]; +// Re-export for backwards compatibility +export const services = fallbackServices; + export const bookmarks: Bookmark[] = [ // Developer Tools { name: 'DevDocs', url: 'https://devdocs.io', icon: 'book', category: 'developer', description: 'API documentation browser' },