Compare commits

..

10 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
16d81c4ec3 Add WhyOps service and show health status for all services
- Add WhyOps (whyops.nuc.lan:3002) to service catalog and registry
- Show status pill for static (non-Coolify) services too
- Merge static services into discovered list so they always appear
- Health-check static services via /api/health endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:54:54 +00:00
Alejandro Gutiérrez
331cd621cf feat: add WhyOps link to WhyRating project card
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:51:12 +00:00
Alejandro Gutiérrez
9b9226b954 Replace Puppeteer with Next.js ImageResponse for previews
- Remove puppeteer (2.9GB) in favor of built-in ImageResponse (0 deps)
- Preview endpoint generates styled deployment card as PNG
- Shows app name, status, branch, commit, duration, domain
- Rename route.ts to route.tsx for JSX support
- Simplify dashboard to use image URL directly
2026-02-06 19:41:40 +01:00
Alejandro Gutiérrez
91624fd6de Add S3/MinIO storage for deployment previews
- Add S3 client helper (src/lib/s3.ts) with upload/download functions
- Add /api/deployments/[uuid]/preview endpoint for presigned URLs
- Update DeploymentDashboard to fetch and display preview images
- Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner

Storage: MinIO bucket nuc-portal-previews with dedicated service account

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:11:50 +01:00
Alejandro Gutiérrez
efc7a8392b Add Vercel-style deployment dashboard
- Add /deployments/[uuid] route with detailed deployment view
- Add DeploymentDashboard component with tabs (Deployment, Logs, Resources, Source)
- Add real-time health/stats via SWR with 10s polling
- Add Docker API helpers (health, stats, uptime) via SSH
- Add redeploy action endpoint and button
- Add expand button to table for inline log viewing
- Add loading skeleton, error, and empty states
- Handle edge cases (in_progress, error, cancelled, missing data)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:00:14 +01:00
Alejandro Gutiérrez
f7c57ca4f0 Use domain-based URLs for services instead of IP:port
- config.ts: Server uses localhost, client uses domain names (coolify.nuc.lan, etc.)
- Added serviceDomains mapping and getServiceUrl() helper
- services.ts: Updated getCoolifyUrl/getDozzleUrl to use domains
- fallbackServices now uses domain-based URLs where available

Works from anywhere via Tailscale (no subnet conflicts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 17:01:11 +01:00
Alejandro Gutiérrez
396b2c3ecf Append real-time stats to CPU/RAM charts on each 15s tick
Zero extra Prometheus queries - reuses existing instant stats data
to grow chart series between 60s range query refreshes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:44:40 +01:00
Alejandro Gutiérrez
e4e5cc97d3 Deep-link Dozzle logs button to specific container
The discover API now fetches container names from Docker via the Python
API on port 9876 and matches them to services by UUID. The Dozzle logs
button builds a proper deep link using the Dozzle host ID and container
name, opening directly to that container's log stream.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:17:46 +01:00
Alejandro Gutiérrez
5aa7559d43 Fix SQL join type mismatch (application_id varchar vs id bigint) and add is_api field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:14:46 +01:00
Alejandro Gutiérrez
68649b0073 Add Dozzle logs button to service card footer
Adds a scroll-text icon button between the web link and Coolify link
that opens Dozzle for viewing container logs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:01:19 +01:00
24 changed files with 4386 additions and 42 deletions

1775
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,15 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.984.0",
"@aws-sdk/s3-request-presigner": "^3.984.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"next": "16.1.6", "next": "16.1.6",
"pg": "^8.18.0", "pg": "^8.18.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0" "recharts": "^3.7.0",
"swr": "^2.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -0,0 +1,128 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import { findContainerByAppName, findContainerByUuid, sshExec, type HealthStatus } from '@/lib/docker';
interface HealthLogEntry {
start: string;
end: string;
exitCode: number;
output: string;
}
interface HealthResponse {
status: HealthStatus | 'unknown';
failingStreak: number;
log: HealthLogEntry[];
containerName: string | null;
lastCheck: string | null;
}
/**
* Get detailed container health including history logs and failing streak.
* Returns the full Health object from docker inspect.
*/
async function getDetailedContainerHealth(containerName: string): Promise<{
status: HealthStatus | null;
failingStreak: number;
log: HealthLogEntry[];
lastCheck: string | null;
}> {
const result = await sshExec(
`docker inspect --format='{{json .State.Health}}' ${containerName} 2>/dev/null`
);
if (!result || result === 'null' || result === '<nil>') {
return { status: null, failingStreak: 0, log: [], lastCheck: null };
}
try {
const health = JSON.parse(result);
const status = health.Status as HealthStatus | undefined;
const failingStreak = health.FailingStreak || 0;
// Map the Log entries
const log: HealthLogEntry[] = (health.Log || []).map((entry: {
Start?: string;
End?: string;
ExitCode?: number;
Output?: string;
}) => ({
start: entry.Start || '',
end: entry.End || '',
exitCode: entry.ExitCode || 0,
output: entry.Output || '',
}));
// Last check is the end time of the most recent log entry
const lastCheck = log.length > 0 ? log[log.length - 1].end : null;
return {
status: status && ['healthy', 'unhealthy', 'starting', 'none'].includes(status)
? status
: null,
failingStreak,
log,
lastCheck,
};
} catch {
return { status: null, failingStreak: 0, log: [], lastCheck: null };
}
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Fetch deployment info to get the application name
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
// Find the container by application UUID first (Coolify naming: uuid-buildnumber)
// Fall back to application name search if UUID doesn't match
let containerName = await findContainerByUuid(deployment.application_uuid);
if (!containerName) {
containerName = await findContainerByAppName(deployment.application_name);
}
if (!containerName) {
// Container not found - return unknown status
const response: HealthResponse = {
status: 'unknown',
failingStreak: 0,
log: [],
containerName: null,
lastCheck: null,
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
}
// Get detailed health information
const health = await getDetailedContainerHealth(containerName);
const response: HealthResponse = {
status: health.status || 'none',
failingStreak: health.failingStreak,
log: health.log,
containerName,
lastCheck: health.lastCheck,
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
} catch (error) {
console.error('Error fetching container health:', error);
return NextResponse.json(
{ error: 'Failed to fetch health status', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,237 @@
import { ImageResponse } from 'next/og';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
export const runtime = 'nodejs';
const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
finished: { color: '#06b6d4', bg: '#0e2a2f', label: 'Ready' },
error: { color: '#ef4444', bg: '#2d1216', label: 'Error' },
in_progress: { color: '#f59e0b', bg: '#2d2305', label: 'Building' },
queued: { color: '#9ca3af', bg: '#1f2028', label: 'Queued' },
cancelled: { color: '#9ca3af', bg: '#1f2028', label: 'Cancelled' },
};
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (mins > 0) return `${mins}m ago`;
return 'just now';
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return new ImageResponse(
(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#0a0a0b',
color: '#6b7280',
fontSize: 24,
}}
>
Deployment not found
</div>
),
{ width: 640, height: 400 }
);
}
const status = STATUS_CONFIG[deployment.status] || STATUS_CONFIG.queued;
const branch = deployment.git_branch || 'main';
const commit = deployment.git_commit_sha?.slice(0, 7) || '—';
const commitMsg = deployment.commit_message
? deployment.commit_message.length > 50
? deployment.commit_message.slice(0, 50) + '...'
: deployment.commit_message
: 'No commit message';
const duration = deployment.duration ? formatDuration(deployment.duration) : '—';
const timeAgo = formatTimeAgo(deployment.created_at);
const fqdn = deployment.application_fqdn?.replace(/^https?:\/\//, '') || '';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
backgroundColor: '#0a0a0b',
padding: '40px',
fontFamily: 'system-ui, sans-serif',
}}
>
{/* Top bar: app name + status */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '32px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
{/* App icon */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '48px',
height: '48px',
borderRadius: '12px',
backgroundColor: '#1a1a1d',
border: '1px solid #2a2a2e',
fontSize: '24px',
}}
>
{deployment.application_name.charAt(0).toUpperCase()}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ color: '#f5f5f5', fontSize: '28px', fontWeight: 700 }}>
{deployment.application_name}
</span>
{fqdn && (
<span style={{ color: '#6b7280', fontSize: '16px' }}>{fqdn}</span>
)}
</div>
</div>
{/* Status badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
borderRadius: '9999px',
backgroundColor: status.bg,
}}
>
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: status.color,
}}
/>
<span style={{ color: status.color, fontSize: '16px', fontWeight: 600 }}>
{status.label}
</span>
</div>
</div>
{/* Divider */}
<div style={{ display: 'flex', width: '100%', height: '1px', backgroundColor: '#1f1f23', marginBottom: '28px' }} />
{/* Metadata grid */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '24px',
marginBottom: '32px',
}}
>
{/* Branch */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '140px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Branch
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{branch}
</span>
</div>
{/* Commit */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '140px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Commit
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontFamily: 'monospace' }}>
{commit}
</span>
</div>
{/* Duration */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '100px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Duration
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{duration}
</span>
</div>
{/* Created */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '100px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Created
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{timeAgo}
</span>
</div>
</div>
{/* Commit message */}
<div
style={{
display: 'flex',
padding: '16px 20px',
borderRadius: '12px',
backgroundColor: '#111114',
border: '1px solid #1f1f23',
marginBottom: '28px',
}}
>
<span style={{ color: '#9ca3af', fontSize: '15px' }}>{commitMsg}</span>
</div>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
}}
>
<span style={{ color: '#4b5563', fontSize: '14px' }}>
{deployment.deployment_uuid.slice(0, 12)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: '#4b5563', fontSize: '14px' }}>NUC Portal</span>
</div>
</div>
</div>
),
{
width: 640,
height: 400,
}
);
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import { triggerDeploy } from '@/lib/coolify';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Get deployment to find application UUID
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
// Trigger deploy via Coolify API using application UUID
const result = await triggerDeploy(deployment.application_uuid);
if (!result.ok) {
return NextResponse.json(
{ error: 'Failed to trigger deployment', details: result.message },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: 'Deployment triggered',
application_uuid: deployment.application_uuid,
});
} catch (error) {
console.error('Redeploy error:', error);
return NextResponse.json(
{ error: 'Failed to redeploy', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import {
findContainerByAppName,
getContainerStats,
getContainerUptime,
formatUptime,
type ContainerStats,
} from '@/lib/docker';
export interface StatsResponse {
containerName: string | null;
stats: ContainerStats | null;
uptime: {
startedAt: string | null;
seconds: number;
formatted: string;
} | null;
timestamp: string;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Fetch deployment info from Coolify to get application_name
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
// Find container by app UUID first (Coolify uses {uuid}-{build} pattern), then by name
let containerName = await findContainerByAppName(deployment.application_uuid);
if (!containerName) {
containerName = await findContainerByAppName(deployment.application_name);
}
// If container not found, return null stats but success response
if (!containerName) {
const response: StatsResponse = {
containerName: null,
stats: null,
uptime: null,
timestamp: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
}
// Fetch stats and uptime in parallel
const [stats, uptimeSeconds] = await Promise.all([
getContainerStats(containerName),
getContainerUptime(containerName),
]);
// Build uptime object
let uptime: StatsResponse['uptime'] = null;
if (uptimeSeconds !== null) {
// Calculate started at from uptime
const startedAt = new Date(Date.now() - uptimeSeconds * 1000).toISOString();
uptime = {
startedAt,
seconds: uptimeSeconds,
formatted: formatUptime(uptimeSeconds),
};
}
const response: StatsResponse = {
containerName,
stats,
uptime,
timestamp: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
} catch (error) {
console.error('Error fetching deployment stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch deployment stats', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -56,9 +56,28 @@ function buildUrl(fqdn: string | null, port: number): string {
return `http://${nucHost}`; return `http://${nucHost}`;
} }
async function fetchContainerNames(): Promise<string[]> {
try {
const res = await fetch(`http://${nucHost}:9876/containers`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
return await res.json() as string[];
} catch {
return [];
}
}
function findContainerName(uuid: string, containers: string[]): string | undefined {
return containers.find(c => c.includes(uuid));
}
export async function GET() { export async function GET() {
try { try {
const resources = await fetchResources(); const [resources, containerNames] = await Promise.all([
fetchResources(),
fetchContainerNames(),
]);
if (!resources) { if (!resources) {
return NextResponse.json( return NextResponse.json(
@@ -88,6 +107,7 @@ export async function GET() {
resourceType: 'application', resourceType: 'application',
uuid: resource.uuid, uuid: resource.uuid,
coolifyStatus: resource.status, coolifyStatus: resource.status,
container: findContainerName(resource.uuid, containerNames),
}; };
} }
@@ -121,6 +141,7 @@ export async function GET() {
resourceType: 'service', resourceType: 'service',
uuid: resource.uuid, uuid: resource.uuid,
coolifyStatus: resource.status, coolifyStatus: resource.status,
container: findContainerName(resource.uuid, containerNames),
}; };
} }
@@ -137,6 +158,7 @@ export async function GET() {
resourceType: 'database', resourceType: 'database',
uuid: resource.uuid, uuid: resource.uuid,
coolifyStatus: resource.status, coolifyStatus: resource.status,
container: findContainerName(resource.uuid, containerNames),
}; };
} }

View File

@@ -0,0 +1,124 @@
'use client';
import { use, useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { DeploymentDashboard } from '@/components/DeploymentDashboard';
import { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from '@/components/DeploymentSkeleton';
import { Icon } from '@/components/Icons';
import type { Deployment } from '@/lib/deployments';
interface DeploymentPageProps {
params: Promise<{ uuid: string }>;
}
export default function DeploymentPage({ params }: DeploymentPageProps) {
const { uuid } = use(params);
const [deployment, setDeployment] = useState<Deployment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDeployment = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/deployments/${uuid}`);
if (!res.ok) {
if (res.status === 404) {
setError('Deployment not found');
} else if (res.status === 500) {
setError('Server error occurred');
} else {
setError('Failed to fetch deployment');
}
return;
}
const data = await res.json();
setDeployment(data);
} catch (err) {
console.error('Fetch deployment error:', err);
if (err instanceof TypeError && err.message.includes('fetch')) {
setError('Network error - please check your connection');
} else {
setError('Failed to fetch deployment');
}
} finally {
setLoading(false);
}
}, [uuid]);
useEffect(() => {
fetchDeployment();
}, [fetchDeployment]);
// Refresh deployment data periodically if in progress
useEffect(() => {
if (!deployment || deployment.status !== 'in_progress') return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/deployments/${uuid}`);
if (res.ok) {
const data = await res.json();
setDeployment(data);
}
} catch {
// Ignore errors during background refresh
}
}, 3000);
return () => clearInterval(interval);
}, [deployment, uuid]);
// Render content based on state
const renderContent = () => {
if (loading) {
return <DeploymentSkeleton />;
}
if (error) {
return <DeploymentError error={error} uuid={uuid} />;
}
if (!deployment) {
return <DeploymentEmpty uuid={uuid} />;
}
return <DeploymentDashboard deployment={deployment} />;
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-stone-950">
{/* Header with breadcrumb */}
<header className="sticky top-0 z-50 bg-white dark:bg-stone-950 border-b border-slate-200 dark:border-stone-800">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 py-4">
{/* Breadcrumb navigation */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/"
className="flex items-center gap-1.5 text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
>
<Icon name="server" size={16} />
<span>NUC Portal</span>
</Link>
<Icon name="chevron-right" size={14} className="text-slate-400 dark:text-stone-600" />
<Link
href="/?tab=deployments"
className="text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
>
Deployments
</Link>
<Icon name="chevron-right" size={14} className="text-slate-400 dark:text-stone-600" />
<span className="text-slate-900 dark:text-stone-100 font-mono">
{uuid.substring(0, 9)}
</span>
</nav>
</div>
</header>
{/* Main content */}
<main className="max-w-[1600px] mx-auto px-4 sm:px-6 py-8">
{renderContent()}
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
'use client';
import Link from 'next/link';
import { Icon } from './Icons';
/**
* Skeleton component for deployment detail page
* Matches the layout of DeploymentDashboard for a seamless loading experience
*/
export function DeploymentSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Header section with app name and actions */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-200 dark:bg-stone-800" />
<div>
<div className="h-6 w-40 bg-slate-200 dark:bg-stone-800 rounded mb-2" />
<div className="h-4 w-24 bg-slate-100 dark:bg-stone-800/50 rounded" />
</div>
</div>
{/* Action buttons skeleton */}
<div className="flex items-center gap-2">
<div className="h-9 w-20 bg-slate-200 dark:bg-stone-800 rounded-lg" />
<div className="h-9 w-20 bg-slate-200 dark:bg-stone-800 rounded-lg" />
<div className="h-9 w-20 bg-slate-900/20 dark:bg-stone-100/20 rounded-lg" />
</div>
</div>
{/* Tab navigation skeleton */}
<div className="border-b border-slate-200 dark:border-stone-800">
<div className="flex gap-6 pb-3">
{['Deployment', 'Logs', 'Resources', 'Source'].map((tab, i) => (
<div
key={tab}
className={`h-5 rounded ${
i === 0
? 'w-24 bg-slate-300 dark:bg-stone-700'
: 'w-16 bg-slate-200 dark:bg-stone-800'
}`}
/>
))}
</div>
</div>
{/* Main content card skeleton */}
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm overflow-hidden">
{/* Card header */}
<div className="flex items-center justify-between p-4 border-b border-slate-100 dark:border-stone-800">
<div className="h-5 w-36 bg-slate-200 dark:bg-stone-800 rounded" />
<div className="flex items-center gap-2">
<div className="h-8 w-16 bg-slate-200 dark:bg-stone-800 rounded-lg" />
<div className="h-8 w-16 bg-slate-200 dark:bg-stone-800 rounded-lg" />
<div className="h-8 w-16 bg-slate-900/20 dark:bg-stone-100/20 rounded-lg" />
</div>
</div>
{/* Main content: Preview + Metadata Grid */}
<div className="p-6">
<div className="flex gap-6">
{/* Preview thumbnail skeleton */}
<div className="flex-shrink-0 w-80">
<div className="aspect-[16/10] bg-slate-100 dark:bg-stone-800 rounded-lg flex items-center justify-center">
<Icon name="image" size={48} className="text-slate-300 dark:text-stone-700" />
</div>
</div>
{/* Metadata grid skeleton */}
<div className="flex-1 grid grid-cols-2 gap-x-8 gap-y-5">
{/* Created */}
<MetadataRowSkeleton />
{/* Status */}
<MetadataRowSkeleton hasIndicator />
{/* Health */}
<MetadataRowSkeleton hasIndicator />
{/* Duration */}
<MetadataRowSkeleton />
{/* Environment */}
<MetadataRowSkeleton hasBadge />
{/* Domains */}
<MetadataRowSkeleton />
{/* Source */}
<MetadataRowSkeleton hasSecondLine />
</div>
</div>
</div>
{/* Collapsible sections skeleton */}
{['Deployment Settings', 'Build Logs', 'Container Stats', 'Deployment Summary'].map(
(section, i) => (
<div
key={section}
className="border-t border-slate-100 dark:border-stone-800 p-4 flex items-center gap-3"
>
<div className="w-4 h-4 bg-slate-200 dark:bg-stone-800 rounded" />
<div className="w-4 h-4 bg-slate-200 dark:bg-stone-800 rounded" />
<div className="h-5 bg-slate-200 dark:bg-stone-800 rounded" style={{ width: `${section.length * 8}px` }} />
{i === 1 && (
<div className="ml-auto flex items-center gap-2">
<div className="h-4 w-12 bg-slate-200 dark:bg-stone-800 rounded" />
<div className="w-2 h-2 bg-slate-300 dark:bg-stone-700 rounded-full" />
</div>
)}
</div>
)
)}
{/* Action Cards Grid skeleton */}
<div className="p-6 border-t border-slate-100 dark:border-stone-800">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<ActionCardSkeleton key={i} />
))}
</div>
</div>
</div>
{/* Footer link skeleton */}
<div className="flex justify-end">
<div className="h-5 w-32 bg-slate-200 dark:bg-stone-800 rounded" />
</div>
</div>
);
}
/**
* Skeleton for metadata rows
*/
function MetadataRowSkeleton({
hasIndicator = false,
hasBadge = false,
hasSecondLine = false,
}: {
hasIndicator?: boolean;
hasBadge?: boolean;
hasSecondLine?: boolean;
}) {
return (
<div>
<div className="h-3 w-16 bg-slate-100 dark:bg-stone-800/50 rounded mb-2" />
<div className="flex items-center gap-2">
{hasIndicator && (
<div className="w-2 h-2 bg-slate-300 dark:bg-stone-700 rounded-full" />
)}
<div className="h-5 w-28 bg-slate-200 dark:bg-stone-800 rounded" />
{hasBadge && (
<div className="h-5 w-16 bg-slate-100 dark:bg-stone-800/50 rounded" />
)}
</div>
{hasSecondLine && (
<div className="h-4 w-48 bg-slate-100 dark:bg-stone-800/50 rounded mt-1" />
)}
</div>
);
}
/**
* Skeleton for action cards
*/
function ActionCardSkeleton() {
return (
<div className="p-4 rounded-lg border border-slate-200 dark:border-stone-700">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-slate-200 dark:bg-stone-800 rounded" />
<div className="h-5 w-24 bg-slate-200 dark:bg-stone-800 rounded" />
</div>
</div>
<div className="h-4 w-full bg-slate-100 dark:bg-stone-800/50 rounded" />
</div>
);
}
/**
* Error state component for deployment page
*/
export function DeploymentError({
error,
uuid,
}: {
error: string;
uuid: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-24">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20 mb-4">
<Icon name="alert-circle" size={32} className="text-red-500" />
</div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
{error}
</h2>
<p className="text-slate-500 dark:text-stone-500 mb-6 text-center max-w-md">
{error === 'Deployment not found' ? (
<>
The deployment with UUID &quot;<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid}</code>&quot; could not be found.
It may have been deleted or never existed.
</>
) : (
<>
Unable to load deployment details. This could be due to a network issue
or the deployment service being temporarily unavailable.
</>
)}
</p>
<div className="flex gap-3">
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 px-4 py-2 text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors"
>
<Icon name="refresh-cw" size={16} />
Retry
</button>
<Link
href="/?tab=deployments"
className="flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-stone-100 text-white dark:text-stone-900 rounded-lg hover:bg-slate-800 dark:hover:bg-stone-200 transition-colors"
>
<Icon name="arrow-left" size={16} />
Back to Deployments
</Link>
</div>
</div>
);
}
/**
* Empty state component for when no deployment data exists
*/
export function DeploymentEmpty({ uuid }: { uuid: string }) {
return (
<div className="flex flex-col items-center justify-center py-24">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-slate-100 dark:bg-stone-800 mb-4">
<Icon name="box" size={32} className="text-slate-400 dark:text-stone-500" />
</div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
No deployment data
</h2>
<p className="text-slate-500 dark:text-stone-500 mb-6 text-center max-w-md">
The deployment &quot;<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid.substring(0, 9)}</code>&quot; exists
but has no data available yet. It may still be initializing.
</p>
<div className="flex gap-3">
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 px-4 py-2 text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors"
>
<Icon name="refresh-cw" size={16} />
Refresh
</button>
<Link
href="/?tab=deployments"
className="flex items-center gap-2 px-4 py-2 bg-slate-900 dark:bg-stone-100 text-white dark:text-stone-900 rounded-lg hover:bg-slate-800 dark:hover:bg-stone-200 transition-colors"
>
<Icon name="arrow-left" size={16} />
Back to Deployments
</Link>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useMemo, useEffect, Fragment } from 'react'; import { useState, useMemo, useEffect, Fragment, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@@ -71,6 +72,7 @@ function LiveDuration({ createdAt }: { createdAt: string }) {
} }
export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) { export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) {
const router = useRouter();
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true }, { id: 'created_at', desc: true },
]); ]);
@@ -79,6 +81,17 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all'); const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
const [appFilter, setAppFilter] = useState<string>('all'); const [appFilter, setAppFilter] = useState<string>('all');
// Toggle expand with stopPropagation to prevent row click navigation
const toggleExpand = useCallback((row: Row<Deployment>, e: React.MouseEvent) => {
e.stopPropagation();
row.toggleExpanded();
}, []);
// Navigate to deployment detail page on row click
const handleRowClick = useCallback((deploymentUuid: string) => {
router.push(`/deployments/${deploymentUuid}`);
}, [router]);
const applicationNames = useMemo(() => { const applicationNames = useMemo(() => {
const names = new Set(deployments.map((d) => d.application_name)); const names = new Set(deployments.map((d) => d.application_name));
return Array.from(names).sort(); return Array.from(names).sort();
@@ -98,13 +111,14 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
header: 'Deployment', header: 'Deployment',
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<button <button
onClick={() => row.toggleExpanded()} onClick={(e) => toggleExpand(row, e)}
className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white font-mono text-sm" className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white font-mono text-sm"
title={row.getIsExpanded() ? 'Collapse logs' : 'Expand logs'}
> >
<Icon <Icon
name="chevron-right" name={row.getIsExpanded() ? 'chevron-down' : 'chevron-right'}
size={14} size={14}
className={`transition-transform ${row.getIsExpanded() ? 'rotate-90' : ''}`} className="transition-transform"
/> />
<span>{getValue().substring(0, 9)}</span> <span>{getValue().substring(0, 9)}</span>
</button> </button>
@@ -239,7 +253,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
), ),
}), }),
], ],
[onDeploy] [onDeploy, toggleExpand]
); );
const table = useReactTable({ const table = useReactTable({
@@ -375,7 +389,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
className={`border-b border-slate-100 dark:border-stone-800/50 hover:bg-slate-50 dark:hover:bg-stone-800/30 cursor-pointer transition-colors ${ className={`border-b border-slate-100 dark:border-stone-800/50 hover:bg-slate-50 dark:hover:bg-stone-800/30 cursor-pointer transition-colors ${
row.getIsExpanded() ? 'bg-slate-50 dark:bg-stone-800/50' : '' row.getIsExpanded() ? 'bg-slate-50 dark:bg-stone-800/50' : ''
}`} }`}
onClick={() => row.toggleExpanded()} onClick={() => handleRowClick(row.original.deployment_uuid)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3"> <td key={cell.id} className="px-4 py-3">

View File

@@ -113,6 +113,15 @@ export const icons: Record<string, React.ComponentType<IconProps>> = {
'power': createIcon('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'), 'power': createIcon('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'),
'stop-circle': createIcon('<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/>'), 'stop-circle': createIcon('<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/>'),
// User & Status
'user': createIcon('<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'),
'clock': createIcon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
'share': createIcon('<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>'),
'webhook': createIcon('<path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/>'),
'alert-circle': createIcon('<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>'),
'x-circle': createIcon('<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>'),
'arrow-left': createIcon('<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>'),
// Navigation // Navigation
'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'), 'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'),
'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'), 'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'),

View File

@@ -28,6 +28,7 @@ const projects: ProjectDef[] = [
icon: 'whyrating', icon: 'whyrating',
apps: [ apps: [
{ name: 'Hub', url: 'http://whyrating.nuc.lan' }, { name: 'Hub', url: 'http://whyrating.nuc.lan' },
{ name: 'WhyOps', url: 'http://whyops.nuc.lan' },
{ name: 'Brand', url: 'http://brand.nuc.lan' }, { name: 'Brand', url: 'http://brand.nuc.lan' },
{ name: 'Templates', url: 'http://templates.nuc.lan' }, { name: 'Templates', url: 'http://templates.nuc.lan' },
], ],

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Service, DiscoveredService, getCoolifyUrl } from '@/lib/services'; import { Service, DiscoveredService, getCoolifyUrl, getDozzleUrl } from '@/lib/services';
import { HealthStatus } from '@/lib/PortalContext'; import { HealthStatus } from '@/lib/PortalContext';
import { Icon } from './Icons'; import { Icon } from './Icons';
@@ -144,8 +144,7 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
{/* Footer: status + links + controls */} {/* Footer: status + links + controls */}
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between"> <div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between">
{/* Left: status pill */} {/* Left: status pill */}
{discovered ? ( <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium ${statusPillStyles[status]}`}>
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium ${statusPillStyles[status]}`}>
<Icon <Icon
name={loading ? 'loader' : statusIcons[status]} name={loading ? 'loader' : statusIcons[status]}
size={10} size={10}
@@ -153,9 +152,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
/> />
{loading ? 'Processing...' : statusLabels[status]} {loading ? 'Processing...' : statusLabels[status]}
</span> </span>
) : (
<span />
)}
{/* Right: links + action buttons */} {/* Right: links + action buttons */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -170,6 +166,19 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
<Icon name="external-link" size={14} /> <Icon name="external-link" size={14} />
</a> </a>
{/* View logs in Dozzle */}
{discovered && (
<a
href={getDozzleUrl(service as DiscoveredService)}
target="_blank"
rel="noopener noreferrer"
title="View logs"
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:text-amber-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
>
<Icon name="scroll-text" size={14} />
</a>
)}
{/* Manage in Coolify */} {/* Manage in Coolify */}
{discovered && ( {discovered && (
<a <a

View File

@@ -7,6 +7,8 @@ export { Header } from './Header';
export { Section } from './ui/Section'; export { Section } from './ui/Section';
export { DeploymentsTable } from './DeploymentsTable'; export { DeploymentsTable } from './DeploymentsTable';
export { DeploymentLogs } from './DeploymentLogs'; export { DeploymentLogs } from './DeploymentLogs';
export { DeploymentDashboard } from './DeploymentDashboard';
export { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from './DeploymentSkeleton';
export { VitalsBar } from './VitalsBar'; export { VitalsBar } from './VitalsBar';
export { OverviewTab } from './OverviewTab'; export { OverviewTab } from './OverviewTab';
export { SystemTrends } from './SystemTrends'; export { SystemTrends } from './SystemTrends';

View File

@@ -93,9 +93,43 @@ export function PortalProvider({ children }: { children: ReactNode }) {
} }
} }
// Active services: discovered or fallback // Health-check static (non-discovered) services via /api/health
const [staticHealth, setStaticHealth] = useState<HealthState>({});
useEffect(() => {
if (discoveredServices.length === 0) return;
const statics = fallbackServices.filter(fb =>
!discoveredServices.some(d => d.name === fb.name || d.port === fb.port)
);
if (statics.length === 0) return;
let cancelled = false;
async function check() {
try {
const res = await fetch('/api/health');
if (!res.ok) return;
const data = await res.json();
if (!cancelled) setStaticHealth(data);
} catch { /* ignore */ }
}
check();
const interval = setInterval(check, 30000);
return () => { cancelled = true; clearInterval(interval); };
}, [discoveredServices.length]);
// Merge static health into healthStatus
for (const [name, status] of Object.entries(staticHealth)) {
if (!healthStatus[name]) {
healthStatus[name] = status;
}
}
// Active services: discovered + any fallback services not already discovered
const activeServices: Service[] = discoveredServices.length > 0 const activeServices: Service[] = discoveredServices.length > 0
? discoveredServices ? [
...discoveredServices,
...fallbackServices.filter(fb =>
!discoveredServices.some(d => d.name === fb.name || d.port === fb.port)
),
]
: fallbackServices; : fallbackServices;
// Filter services and bookmarks // Filter services and bookmarks

View File

@@ -1,19 +1,66 @@
// Server-side configuration (only available in API routes / server components) // Server-side configuration (only available in API routes / server components)
// Note: Server runs on NUC, so it can use localhost or container names for internal access
export const serverConfig = { export const serverConfig = {
coolifyToken: process.env.COOLIFY_API_TOKEN || '', coolifyToken: process.env.COOLIFY_API_TOKEN || '',
coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://192.168.1.3:8000/api/v1', coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://localhost:8000/api/v1',
coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss', coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss',
coolifyDbUrl: process.env.COOLIFY_DB_URL || '', coolifyDbUrl: process.env.COOLIFY_DB_URL || '',
prometheusUrl: process.env.PROMETHEUS_URL || 'http://192.168.1.3:9091', prometheusUrl: process.env.PROMETHEUS_URL || 'http://localhost:9091',
nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || '192.168.1.3:9100', nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || 'localhost:9100',
nicDevice: process.env.NIC_DEVICE || 'eno1', nicDevice: process.env.NIC_DEVICE || 'eno1',
nucHost: process.env.NUC_HOST || '192.168.1.3', nucHost: process.env.NUC_HOST || 'localhost',
}; };
// Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix) // Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix)
// Uses domain names for browser access (works via Tailscale from anywhere)
export const clientConfig = { export const clientConfig = {
nucHost: process.env.NEXT_PUBLIC_NUC_HOST || '192.168.1.3', // Primary domain-based URLs (preferred - work from anywhere)
coolifyUrl: process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://192.168.1.3:8000', coolifyUrl: process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://coolify.nuc.lan',
grafanaUrl: process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://grafana.nuc.lan',
dozzleUrl: process.env.NEXT_PUBLIC_DOZZLE_URL || 'http://dozzle.nuc.lan',
// Fallback host for services without domain routes
nucHost: process.env.NEXT_PUBLIC_NUC_HOST || '100.113.153.45',
// Coolify project identifiers
coolifyProjectUuid: process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0', coolifyProjectUuid: process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0',
grafanaUrl: process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://192.168.1.3:3333', coolifyEnvUuid: process.env.NEXT_PUBLIC_COOLIFY_ENV_UUID || 'dckc0w4ko8s888c4gk84skoo',
dozzleHostId: process.env.NEXT_PUBLIC_DOZZLE_HOST_ID || '6c1738d9-6f12-4ed7-9293-70a91f407347',
}; };
// Domain mappings for services (used for generating URLs)
export const serviceDomains: Record<string, string> = {
coolify: 'http://coolify.nuc.lan',
gitea: 'http://gitea.nuc.lan',
outline: 'http://outline.nuc.lan',
files: 'http://files.nuc.lan',
filebrowser: 'http://files.nuc.lan',
mail: 'http://mail.nuc.lan',
snappymail: 'http://mail.nuc.lan',
vault: 'http://vault.nuc.lan',
vaultwarden: 'http://vault.nuc.lan',
homepage: 'http://homepage.nuc.lan',
grafana: 'http://grafana.nuc.lan',
dozzle: 'http://dozzle.nuc.lan',
};
/**
* Get the URL for a service, preferring domain-based URL if available
*/
export function getServiceUrl(serviceName: string, port?: number): string {
const lower = serviceName.toLowerCase();
// Check for domain mapping first
for (const [key, url] of Object.entries(serviceDomains)) {
if (lower.includes(key)) {
return url;
}
}
// Fallback to port-based URL
if (port) {
return `http://${clientConfig.nucHost}:${port}`;
}
return `http://${clientConfig.nucHost}`;
}

View File

@@ -58,6 +58,7 @@ function rowToDeployment(row: Record<string, unknown>): Deployment {
git_commit_sha: (row.commit as string) || undefined, git_commit_sha: (row.commit as string) || undefined,
commit_message: (row.commit_message as string) || undefined, commit_message: (row.commit_message as string) || undefined,
is_webhook: row.is_webhook as boolean | undefined, is_webhook: row.is_webhook as boolean | undefined,
is_api: row.is_api as boolean | undefined,
logs: (row.logs as string) || undefined, logs: (row.logs as string) || undefined,
duration, duration,
}; };
@@ -69,7 +70,7 @@ export async function fetchDeployments(limit = 50): Promise<Deployment[]> {
SELECT SELECT
q.deployment_uuid, q.deployment_uuid,
a.uuid AS application_uuid, a.uuid AS application_uuid,
a.name AS application_name, COALESCE(q.application_name, a.name) AS application_name,
a.fqdn AS application_fqdn, a.fqdn AS application_fqdn,
q.status, q.status,
q.created_at, q.created_at,
@@ -77,9 +78,10 @@ export async function fetchDeployments(limit = 50): Promise<Deployment[]> {
a.git_branch, a.git_branch,
q.commit, q.commit,
q.commit_message, q.commit_message,
q.is_webhook q.is_webhook,
q.is_api
FROM application_deployment_queues q FROM application_deployment_queues q
LEFT JOIN applications a ON a.id = q.application_id LEFT JOIN applications a ON a.id = q.application_id::bigint
ORDER BY q.created_at DESC ORDER BY q.created_at DESC
LIMIT $1 LIMIT $1
`, [limit]); `, [limit]);
@@ -106,7 +108,7 @@ export async function fetchDeploymentDetail(uuid: string): Promise<Deployment |
SELECT SELECT
q.deployment_uuid, q.deployment_uuid,
a.uuid AS application_uuid, a.uuid AS application_uuid,
a.name AS application_name, COALESCE(q.application_name, a.name) AS application_name,
a.fqdn AS application_fqdn, a.fqdn AS application_fqdn,
q.status, q.status,
q.created_at, q.created_at,
@@ -115,9 +117,10 @@ export async function fetchDeploymentDetail(uuid: string): Promise<Deployment |
q.commit, q.commit,
q.commit_message, q.commit_message,
q.is_webhook, q.is_webhook,
q.is_api,
q.logs q.logs
FROM application_deployment_queues q FROM application_deployment_queues q
LEFT JOIN applications a ON a.id = q.application_id LEFT JOIN applications a ON a.id = q.application_id::bigint
WHERE q.deployment_uuid = $1 WHERE q.deployment_uuid = $1
`, [uuid]); `, [uuid]);

View File

@@ -13,6 +13,7 @@ export interface Deployment {
git_commit_sha?: string; git_commit_sha?: string;
commit_message?: string; commit_message?: string;
is_webhook?: boolean; is_webhook?: boolean;
is_api?: boolean;
logs?: string; logs?: string;
// Computed fields // Computed fields
duration?: number; // in seconds duration?: number; // in seconds

264
src/lib/docker.ts Normal file
View File

@@ -0,0 +1,264 @@
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// SSH host configured in ~/.ssh/config
const SSH_HOST = 'nuc';
// Timeout for SSH commands (10 seconds)
const SSH_TIMEOUT = 10000;
export interface ContainerStats {
cpuPercent: number;
memoryUsage: string;
memoryLimit: string;
memoryPercent: number;
netIO: { rx: string; tx: string };
blockIO: { read: string; write: string };
}
export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'none';
/**
* Execute a command via SSH to the NUC server.
* Returns stdout on success, null on error.
*/
export async function sshExec(command: string): Promise<string | null> {
try {
const { stdout } = await execAsync(`ssh ${SSH_HOST} "${command.replace(/"/g, '\\"')}"`, {
timeout: SSH_TIMEOUT,
});
return stdout.trim();
} catch {
return null;
}
}
/**
* Get the health status of a container.
* Returns 'healthy', 'unhealthy', 'starting', 'none' (no healthcheck configured),
* or null if container doesn't exist.
*/
export async function getContainerHealth(containerName: string): Promise<HealthStatus | null> {
// First check if container exists
const exists = await sshExec(
`docker inspect --format='{{.State.Status}}' ${containerName} 2>/dev/null`
);
if (!exists) return null;
// Now get health status
const result = await sshExec(
`docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ${containerName} 2>/dev/null`
);
if (!result) return null;
const status = result.trim();
if (status === 'none' || status === '' || status === '<nil>') {
return 'none';
}
if (['healthy', 'unhealthy', 'starting'].includes(status)) {
return status as HealthStatus;
}
return 'none';
}
/**
* Get container resource statistics (CPU, memory, network I/O, block I/O).
* Returns null if container not found or not running.
*/
export async function getContainerStats(containerName: string): Promise<ContainerStats | null> {
const result = await sshExec(
`docker stats --no-stream --format='{{.CPUPerc}},{{.MemUsage}},{{.NetIO}},{{.BlockIO}}' ${containerName} 2>/dev/null`
);
if (!result) return null;
// Format: "0.08%,130.2MiB / 7.648GiB,27MB / 6.92MB,4.1kB / 4.1kB"
const parts = result.split(',');
if (parts.length < 4) return null;
const cpuStr = parts[0].replace('%', '').trim();
const cpuPercent = parseFloat(cpuStr) || 0;
// Memory: "130.2MiB / 7.648GiB"
const memParts = parts[1].split('/').map((s) => s.trim());
const memoryUsage = memParts[0] || '0';
const memoryLimit = memParts[1] || '0';
// Calculate memory percentage
const memUsageBytes = parseMemoryToBytes(memoryUsage);
const memLimitBytes = parseMemoryToBytes(memoryLimit);
const memoryPercent = memLimitBytes > 0 ? (memUsageBytes / memLimitBytes) * 100 : 0;
// Network I/O: "27MB / 6.92MB"
const netParts = parts[2].split('/').map((s) => s.trim());
const netIO = { rx: netParts[0] || '0', tx: netParts[1] || '0' };
// Block I/O: "4.1kB / 4.1kB"
const blockParts = parts[3].split('/').map((s) => s.trim());
const blockIO = { read: blockParts[0] || '0', write: blockParts[1] || '0' };
return {
cpuPercent,
memoryUsage,
memoryLimit,
memoryPercent,
netIO,
blockIO,
};
}
/**
* Parse memory string like "130.2MiB" or "7.648GiB" to bytes.
*/
function parseMemoryToBytes(memStr: string): number {
const match = memStr.match(/^([\d.]+)\s*(\w+)?$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
const units: Record<string, number> = {
B: 1,
KB: 1024,
KIB: 1024,
MB: 1024 * 1024,
MIB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
GIB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
TIB: 1024 * 1024 * 1024 * 1024,
};
return value * (units[unit] || 1);
}
/**
* Get container uptime in seconds.
* Returns null if container not found or not running.
*/
export async function getContainerUptime(containerName: string): Promise<number | null> {
const result = await sshExec(
`docker inspect --format='{{.State.StartedAt}}' ${containerName} 2>/dev/null`
);
if (!result || result === '0001-01-01T00:00:00Z') return null;
try {
const startTime = new Date(result.trim());
if (isNaN(startTime.getTime())) return null;
const now = new Date();
const uptimeMs = now.getTime() - startTime.getTime();
return Math.max(0, Math.floor(uptimeMs / 1000));
} catch {
return null;
}
}
/**
* Format uptime seconds to human-readable string.
* e.g., "2d 5h 30m" or "45m 12s"
*/
export function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
return parts.join(' ') || '0m';
}
/**
* Find container name by app/service name.
* Searches through running containers for matches.
* Returns the first matching container name, or null if not found.
*/
export async function findContainerByAppName(appName: string): Promise<string | null> {
// Get list of all container names
const result = await sshExec(`docker ps -a --format='{{.Names}}'`);
if (!result) return null;
const containers = result.split('\n').filter((name) => name.trim());
const searchName = appName.toLowerCase();
// Try exact match first
const exactMatch = containers.find(
(c) => c.toLowerCase() === searchName
);
if (exactMatch) return exactMatch;
// Try prefix match (e.g., "outline" matches "outline-pccg80wks4c084008owokkkg")
const prefixMatch = containers.find(
(c) => c.toLowerCase().startsWith(searchName + '-') || c.toLowerCase().startsWith(searchName)
);
if (prefixMatch) return prefixMatch;
// Try contains match
const containsMatch = containers.find(
(c) => c.toLowerCase().includes(searchName)
);
if (containsMatch) return containsMatch;
return null;
}
/**
* Get container status (running, exited, etc.)
*/
export async function getContainerStatus(containerName: string): Promise<string | null> {
const result = await sshExec(
`docker inspect --format='{{.State.Status}}' ${containerName} 2>/dev/null`
);
return result || null;
}
/**
* Check if a container exists and is running.
*/
export async function isContainerRunning(containerName: string): Promise<boolean> {
const status = await getContainerStatus(containerName);
return status === 'running';
}
/**
* Find container name by application UUID.
* Coolify container names typically start with the application UUID followed by a hyphen and build number.
* e.g., "t80w0cw0oooc4g0soswos4so-160146641903" for application UUID "t80w0cw0oooc4g0soswos4so"
* Returns the first matching container name, or null if not found.
*/
export async function findContainerByUuid(appUuid: string): Promise<string | null> {
if (!appUuid || appUuid === 'unknown') return null;
// Get list of all container names
const result = await sshExec(`docker ps -a --format='{{.Names}}'`);
if (!result) return null;
const containers = result.split('\n').filter((name) => name.trim());
const searchUuid = appUuid.toLowerCase();
// Try exact UUID prefix match (most common for Coolify apps)
// Container names are like: uuid-buildnumber (e.g., t80w0cw0oooc4g0soswos4so-160146641903)
const prefixMatch = containers.find(
(c) => c.toLowerCase().startsWith(searchUuid + '-') || c.toLowerCase() === searchUuid
);
if (prefixMatch) return prefixMatch;
// Try contains match (for service containers like outline-pccg80...)
const containsMatch = containers.find(
(c) => c.toLowerCase().includes(searchUuid)
);
if (containsMatch) return containsMatch;
return null;
}

170
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,170 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// S3/MinIO client configuration
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
forcePathStyle: true, // Required for MinIO
});
const BUCKET = process.env.S3_BUCKET || 'nuc-portal-previews';
/**
* Upload a file to S3/MinIO
*/
export async function uploadFile(
key: string,
body: Buffer | Uint8Array | string,
contentType: string = 'application/octet-stream'
): Promise<{ success: boolean; key: string; error?: string }> {
try {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
})
);
return { success: true, key };
} catch (error) {
console.error('S3 upload error:', error);
return { success: false, key, error: String(error) };
}
}
/**
* Upload a deployment preview screenshot
*/
export async function uploadPreviewScreenshot(
appUuid: string,
deploymentUuid: string,
imageBuffer: Buffer
): Promise<{ success: boolean; key: string; url?: string; error?: string }> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
const result = await uploadFile(key, imageBuffer, 'image/png');
if (result.success) {
// Return the direct URL (MinIO serves files directly if bucket is public)
// Or use presigned URL for private buckets
const url = `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`;
return { ...result, url };
}
return result;
}
/**
* Get a presigned URL for reading a file (valid for 1 hour by default)
*/
export async function getPresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string | null> {
try {
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: key,
});
return await getSignedUrl(s3Client, command, { expiresIn });
} catch (error) {
console.error('S3 presigned URL error:', error);
return null;
}
}
/**
* Get presigned URL for a deployment preview
*/
export async function getPreviewUrl(
appUuid: string,
deploymentUuid: string
): Promise<string | null> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
return getPresignedUrl(key);
}
/**
* Check if a file exists in S3
*/
export async function fileExists(key: string): Promise<boolean> {
try {
await s3Client.send(
new HeadObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
return true;
} catch {
return false;
}
}
/**
* Check if a preview exists for a deployment
*/
export async function previewExists(
appUuid: string,
deploymentUuid: string
): Promise<boolean> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
return fileExists(key);
}
/**
* Delete a file from S3
*/
export async function deleteFile(key: string): Promise<boolean> {
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
return true;
} catch (error) {
console.error('S3 delete error:', error);
return false;
}
}
/**
* Get file as Buffer
*/
export async function getFile(key: string): Promise<Buffer | null> {
try {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
if (response.Body) {
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
return null;
} catch (error) {
console.error('S3 get error:', error);
return null;
}
}
export { s3Client, BUCKET };

View File

@@ -66,6 +66,8 @@ const registry: Record<string, ServiceMeta> = {
// Apps // Apps
'googlescraper': { icon: 'search', category: 'automation', description: 'Google scraper API' }, 'googlescraper': { icon: 'search', category: 'automation', description: 'Google scraper API' },
'whyops': { icon: 'settings', category: 'automation', description: 'WhyRating scraping, pipelines & testing' },
'whyrating-dashboard': { icon: 'settings', category: 'automation', description: 'WhyRating scraping, pipelines & testing' },
'actionkit landing': { icon: 'layout', category: 'development', description: 'Landing page builder' }, 'actionkit landing': { icon: 'layout', category: 'development', description: 'Landing page builder' },
}; };

View File

@@ -19,6 +19,22 @@ export interface DiscoveredService extends Service {
coolifyStatus: string; coolifyStatus: string;
} }
export function getCoolifyUrl(service: DiscoveredService): string {
const base = process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://coolify.nuc.lan';
const project = process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0';
const env = process.env.NEXT_PUBLIC_COOLIFY_ENV_UUID || 'dckc0w4ko8s888c4gk84skoo';
return `${base}/project/${project}/environment/${env}/${service.resourceType}/${service.uuid}`;
}
export function getDozzleUrl(service?: DiscoveredService): string {
const base = process.env.NEXT_PUBLIC_DOZZLE_URL || 'http://dozzle.nuc.lan';
const hostId = process.env.NEXT_PUBLIC_DOZZLE_HOST_ID || '6c1738d9-6f12-4ed7-9293-70a91f407347';
if (service?.container) {
return `${base}/container/${hostId}~${service.container}`;
}
return base;
}
export interface Bookmark { export interface Bookmark {
name: string; name: string;
url: string; url: string;
@@ -27,30 +43,31 @@ export interface Bookmark {
description?: string; description?: string;
} }
import { clientConfig } from './config'; import { clientConfig, getServiceUrl } from './config';
const h = clientConfig.nucHost; const h = clientConfig.nucHost;
export const fallbackServices: Service[] = [ export const fallbackServices: Service[] = [
// Infrastructure // Infrastructure - prefer domain-based URLs
{ name: 'Coolify', url: `http://${h}:8000`, port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' }, { name: 'Coolify', url: 'http://coolify.nuc.lan', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
{ name: 'Dozzle', url: `http://${h}:9999`, port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' }, { name: 'Dozzle', url: 'http://dozzle.nuc.lan', port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' },
{ name: 'Playwriter Browser', url: `http://${h}:6081/vnc.html`, port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' }, { name: 'Playwriter Browser', url: `http://${h}:6081/vnc.html`, port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' },
// Automation // Automation
{ name: 'n8n', url: `http://${h}:5678`, port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' }, { name: 'n8n', url: `http://${h}:5678`, port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' },
// Development // Development - prefer domain-based URLs
{ name: 'Gitea', url: `http://${h}:3030`, port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' }, { name: 'Gitea', url: 'http://gitea.nuc.lan', port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' },
{ name: 'CloudBeaver', url: `http://${h}:8978`, port: 8978, icon: 'database', category: 'development', description: 'Database management UI' }, { name: 'CloudBeaver', url: `http://${h}:8978`, port: 8978, icon: 'database', category: 'development', description: 'Database management UI' },
{ name: 'Adminer', url: `http://${h}:8088`, port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' }, { name: 'Adminer', url: `http://${h}:8088`, port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' },
{ name: 'WhyOps', url: 'http://whyops.nuc.lan', port: 3002, icon: 'settings', category: 'automation', description: 'WhyRating scraping, pipelines & testing' },
// Knowledge // Knowledge - prefer domain-based URLs
{ name: 'Outline', url: `http://${h}:3080`, port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' }, { name: 'Outline', url: 'http://outline.nuc.lan', port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' },
{ name: 'NocoDB', url: `http://${h}:8084`, port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' }, { name: 'NocoDB', url: `http://${h}:8084`, port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' },
// Storage // Storage - prefer domain-based URLs
{ name: 'FileBrowser', url: `http://${h}:8085`, port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' }, { name: 'FileBrowser', url: 'http://files.nuc.lan', port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' },
{ name: 'MinIO', url: `http://${h}:9001`, port: 9001, icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' }, { name: 'MinIO', url: `http://${h}:9001`, port: 9001, icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' },
{ name: 'Kopia', url: `http://${h}:51515`, port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' }, { name: 'Kopia', url: `http://${h}:51515`, port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' },
@@ -58,8 +75,8 @@ export const fallbackServices: Service[] = [
{ name: 'Uptime Kuma', url: `http://${h}:3001`, port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' }, { name: 'Uptime Kuma', url: `http://${h}:3001`, port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' },
{ name: 'Ntfy', url: `http://${h}:8333`, port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' }, { name: 'Ntfy', url: `http://${h}:8333`, port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' },
// Security // Security - prefer domain-based URLs
{ name: 'Vaultwarden', url: `http://${h}:8222`, port: 8222, icon: 'lock', category: 'security', description: 'Password manager' }, { name: 'Vaultwarden', url: 'http://vault.nuc.lan', port: 8222, icon: 'lock', category: 'security', description: 'Password manager' },
{ name: 'Authentik', url: `http://${h}:9090`, port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' }, { name: 'Authentik', url: `http://${h}:9090`, port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' },
]; ];

View File

@@ -68,7 +68,28 @@ export function useEventStream() {
es.addEventListener('stats', (e) => { es.addEventListener('stats', (e) => {
try { try {
const stats = JSON.parse(e.data); const stats = JSON.parse(e.data);
setState(prev => ({ ...prev, stats })); setState(prev => {
// Append latest stats as new chart data points (real-time feed)
if (prev.metrics) {
const now = Math.floor(Date.now() / 1000);
const sixHoursAgo = now - 6 * 3600;
const append = (series: Array<[number, number]>, value: number): Array<[number, number]> => {
const filtered = series.filter(([ts]) => ts > sixHoursAgo);
filtered.push([now, value]);
return filtered;
};
return {
...prev,
stats,
metrics: {
...prev.metrics,
cpu: append(prev.metrics.cpu, stats.cpu_percent),
ram: append(prev.metrics.ram, stats.ram_percent),
},
};
}
return { ...prev, stats };
});
} catch { /* ignore */ } } catch { /* ignore */ }
}); });