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:
Alejandro Gutiérrez
2026-02-03 21:28:02 +01:00
parent 3a16df2581
commit d70f7a902f
6 changed files with 526 additions and 13 deletions

View 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' } }
);
}
}

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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
View 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);
}

View File

@@ -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' },