Compare commits
10 Commits
af49497923
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d81c4ec3 | ||
|
|
331cd621cf | ||
|
|
9b9226b954 | ||
|
|
91624fd6de | ||
|
|
efc7a8392b | ||
|
|
f7c57ca4f0 | ||
|
|
396b2c3ecf | ||
|
|
e4e5cc97d3 | ||
|
|
5aa7559d43 | ||
|
|
68649b0073 |
1775
package-lock.json
generated
1775
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
128
src/app/api/deployments/[uuid]/health/route.ts
Normal file
128
src/app/api/deployments/[uuid]/health/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/app/api/deployments/[uuid]/preview/route.tsx
Normal file
237
src/app/api/deployments/[uuid]/preview/route.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/api/deployments/[uuid]/redeploy/route.ts
Normal file
41
src/app/api/deployments/[uuid]/redeploy/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/app/api/deployments/[uuid]/stats/route.ts
Normal file
89
src/app/api/deployments/[uuid]/stats/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
124
src/app/deployments/[uuid]/page.tsx
Normal file
124
src/app/deployments/[uuid]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1073
src/components/DeploymentDashboard.tsx
Normal file
1073
src/components/DeploymentDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
260
src/components/DeploymentSkeleton.tsx
Normal file
260
src/components/DeploymentSkeleton.tsx
Normal 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 "<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid}</code>" 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 "<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid.substring(0, 9)}</code>" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"/>'),
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
264
src/lib/docker.ts
Normal 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
170
src/lib/s3.ts
Normal 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 };
|
||||||
@@ -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' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user