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,
|
||||
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
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 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-900 dark:text-stone-100">{totalServices}</span>
|
||||
</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">
|
||||
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
||||
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</span>
|
||||
|
||||
@@ -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<HealthStatus, string> = {
|
||||
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 (
|
||||
<a
|
||||
href={service.url}
|
||||
@@ -33,6 +62,11 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<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
|
||||
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
|
||||
title={statusLabels[status]}
|
||||
@@ -58,9 +92,18 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Port badge */}
|
||||
<div className="mt-3 text-xs text-slate-400 dark:text-stone-600 font-mono">
|
||||
:{service.port}
|
||||
{/* Badge: FQDN subdomain or port */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{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>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
// Discovery
|
||||
discoveredServices: DiscoveredService[];
|
||||
discoveryLoading: boolean;
|
||||
discoveryError: boolean;
|
||||
refreshDiscover: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PortalContext = createContext<PortalContextType | undefined>(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<DiscoveredService[]>([]);
|
||||
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 (
|
||||
<PortalContext.Provider
|
||||
value={{
|
||||
services,
|
||||
services: activeServices,
|
||||
bookmarks,
|
||||
healthStatus,
|
||||
darkMode,
|
||||
@@ -157,6 +217,10 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
||||
refreshDeployments,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
discoveredServices,
|
||||
discoveryLoading,
|
||||
discoveryError,
|
||||
refreshDiscover,
|
||||
}}
|
||||
>
|
||||
{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;
|
||||
}
|
||||
|
||||
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' },
|
||||
|
||||
Reference in New Issue
Block a user