Add auto-discovery services tab using Coolify API
Replace static services list with dynamic discovery from Coolify's server resources API. Services are auto-categorized using a registry of known service names mapped to icons and categories. Falls back to static list with health checks when Coolify is unreachable. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
248
src/app/api/discover/route.ts
Normal file
248
src/app/api/discover/route.ts
Normal file
@@ -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<T>(url: string): Promise<T | null> {
|
||||||
|
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<CoolifyResource[]>(
|
||||||
|
`${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<DiscoveredService | null> => {
|
||||||
|
const healthStatus = mapCoolifyStatus(resource.status);
|
||||||
|
|
||||||
|
if (resource.type === 'application') {
|
||||||
|
const detail = await fetchJson<CoolifyAppDetail>(
|
||||||
|
`${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<CoolifyServiceDetail>(
|
||||||
|
`${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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ export default function Home() {
|
|||||||
refreshDeployments,
|
refreshDeployments,
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
discoveredServices,
|
||||||
|
discoveryLoading,
|
||||||
|
discoveryError,
|
||||||
} = usePortal();
|
} = usePortal();
|
||||||
|
|
||||||
// Group services by category
|
// Group services by category
|
||||||
@@ -73,6 +76,9 @@ export default function Home() {
|
|||||||
const runningCount = services.filter(s => healthStatus[s.name] === 'running').length;
|
const runningCount = services.filter(s => healthStatus[s.name] === 'running').length;
|
||||||
const totalServices = services.length;
|
const totalServices = services.length;
|
||||||
|
|
||||||
|
// Discovery source label
|
||||||
|
const isDiscovered = discoveredServices.length > 0;
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'services':
|
case 'services':
|
||||||
@@ -91,6 +97,21 @@ export default function Home() {
|
|||||||
{runningCount} of {totalServices} services running
|
{runningCount} of {totalServices} services running
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isDiscovered && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400">
|
||||||
|
Auto-discovered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{discoveryLoading && !isDiscovered && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-500 animate-pulse">
|
||||||
|
Discovering services...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{discoveryError && !isDiscovered && (
|
||||||
|
<span className="text-xs text-amber-500">
|
||||||
|
Using static list (Coolify unavailable)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No results message */}
|
{/* No results message */}
|
||||||
@@ -299,6 +320,12 @@ export default function Home() {
|
|||||||
<span className="text-slate-500 dark:text-stone-500">Services</span>
|
<span className="text-slate-500 dark:text-stone-500">Services</span>
|
||||||
<span className="text-slate-900 dark:text-stone-100">{totalServices}</span>
|
<span className="text-slate-900 dark:text-stone-100">{totalServices}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Discovery</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100">
|
||||||
|
{isDiscovered ? 'Coolify API' : 'Static'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
||||||
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</span>
|
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Service } from '@/lib/services';
|
import { Service, DiscoveredService } from '@/lib/services';
|
||||||
import { HealthStatus } from '@/lib/PortalContext';
|
import { HealthStatus } from '@/lib/PortalContext';
|
||||||
import { Icon } from './Icons';
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
@@ -23,7 +23,36 @@ const statusLabels: Record<HealthStatus, string> = {
|
|||||||
loading: 'Checking...',
|
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) {
|
export function ServiceCard({ service, status }: ServiceCardProps) {
|
||||||
|
const fqdnLabel = getFqdnLabel(service);
|
||||||
|
const resourceBadge = getResourceBadge(service);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={service.url}
|
href={service.url}
|
||||||
@@ -33,6 +62,11 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
>
|
>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="absolute top-3 right-3 flex items-center gap-1.5">
|
<div className="absolute top-3 right-3 flex items-center gap-1.5">
|
||||||
|
{resourceBadge && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-400">
|
||||||
|
{resourceBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
|
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
|
||||||
title={statusLabels[status]}
|
title={statusLabels[status]}
|
||||||
@@ -58,9 +92,18 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Port badge */}
|
{/* Badge: FQDN subdomain or port */}
|
||||||
<div className="mt-3 text-xs text-slate-400 dark:text-stone-600 font-mono">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
:{service.port}
|
{fqdnLabel && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-mono">
|
||||||
|
{fqdnLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{service.port > 0 && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-600 font-mono">
|
||||||
|
:{service.port}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
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';
|
import type { Deployment } from './deployments';
|
||||||
|
|
||||||
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
|
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
|
||||||
@@ -28,6 +28,11 @@ interface PortalContextType {
|
|||||||
refreshDeployments: () => Promise<void>;
|
refreshDeployments: () => Promise<void>;
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
setActiveTab: (tab: string) => void;
|
setActiveTab: (tab: string) => void;
|
||||||
|
// Discovery
|
||||||
|
discoveredServices: DiscoveredService[];
|
||||||
|
discoveryLoading: boolean;
|
||||||
|
discoveryError: boolean;
|
||||||
|
refreshDiscover: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortalContext = createContext<PortalContextType | undefined>(undefined);
|
const PortalContext = createContext<PortalContextType | undefined>(undefined);
|
||||||
@@ -48,6 +53,11 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
|
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('whyrating');
|
const [activeTab, setActiveTab] = useState('whyrating');
|
||||||
|
|
||||||
|
// Discovery state
|
||||||
|
const [discoveredServices, setDiscoveredServices] = useState<DiscoveredService[]>([]);
|
||||||
|
const [discoveryLoading, setDiscoveryLoading] = useState(true);
|
||||||
|
const [discoveryError, setDiscoveryError] = useState(false);
|
||||||
|
|
||||||
// Apply dark mode to document
|
// Apply dark mode to document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@@ -69,7 +79,7 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('portal-dark-mode', String(darkMode));
|
localStorage.setItem('portal-dark-mode', String(darkMode));
|
||||||
}, [darkMode]);
|
}, [darkMode]);
|
||||||
|
|
||||||
// Fetch health status
|
// Fetch health status (used as fallback when discovery is unavailable)
|
||||||
const refreshHealth = useCallback(async () => {
|
const refreshHealth = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
refreshHealth();
|
refreshDiscover();
|
||||||
const interval = setInterval(refreshHealth, 30000); // Refresh every 30 seconds
|
const interval = setInterval(refreshDiscover, 30000);
|
||||||
return () => clearInterval(interval);
|
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
|
// Fetch deployments
|
||||||
const refreshDeployments = useCallback(async () => {
|
const refreshDeployments = useCallback(async () => {
|
||||||
@@ -117,8 +172,13 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [activeTab, refreshDeployments]);
|
}, [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
|
// Filter services and bookmarks based on search query
|
||||||
const filteredServices = services.filter(service => {
|
const filteredServices = activeServices.filter(service => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -141,7 +201,7 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<PortalContext.Provider
|
<PortalContext.Provider
|
||||||
value={{
|
value={{
|
||||||
services,
|
services: activeServices,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
healthStatus,
|
healthStatus,
|
||||||
darkMode,
|
darkMode,
|
||||||
@@ -157,6 +217,10 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
refreshDeployments,
|
refreshDeployments,
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
discoveredServices,
|
||||||
|
discoveryLoading,
|
||||||
|
discoveryError,
|
||||||
|
refreshDiscover,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
120
src/lib/service-registry.ts
Normal file
120
src/lib/service-registry.ts
Normal file
@@ -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<string, ServiceMeta> = {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ export interface Service {
|
|||||||
container?: string;
|
container?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredService extends Service {
|
||||||
|
source: 'discovered' | 'static';
|
||||||
|
fqdn?: string;
|
||||||
|
resourceType: 'application' | 'service' | 'database';
|
||||||
|
uuid: string;
|
||||||
|
coolifyStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Bookmark {
|
export interface Bookmark {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -19,7 +27,7 @@ export interface Bookmark {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const services: Service[] = [
|
export const fallbackServices: Service[] = [
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
{ name: 'Coolify', url: 'http://192.168.1.3:8000', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
|
{ 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' },
|
{ 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' },
|
{ 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[] = [
|
export const bookmarks: Bookmark[] = [
|
||||||
// Developer Tools
|
// Developer Tools
|
||||||
{ name: 'DevDocs', url: 'https://devdocs.io', icon: 'book', category: 'developer', description: 'API documentation browser' },
|
{ name: 'DevDocs', url: 'https://devdocs.io', icon: 'book', category: 'developer', description: 'API documentation browser' },
|
||||||
|
|||||||
Reference in New Issue
Block a user