Add NUC Portal - infrastructure dashboard
Next.js 16 dashboard for managing NUC services via Coolify API. Features service cards with health indicators, deployment dashboard with live log streaming, S3-backed preview images, SSE real-time updates, and dark mode support. 18 services across 7 categories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
nuc-portal/.gitignore
vendored
Normal file
41
nuc-portal/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
27
nuc-portal/README.md
Normal file
27
nuc-portal/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# NUC Portal
|
||||||
|
|
||||||
|
Self-hosted services dashboard for the NUC server at 192.168.1.3.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Service cards with live health status indicators
|
||||||
|
- Bookmark links to external developer tools
|
||||||
|
- Dark/light mode toggle
|
||||||
|
- Search filtering across services and bookmarks
|
||||||
|
- Category-based organization
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deployed via Coolify with Nixpacks.
|
||||||
|
|
||||||
|
- FQDN: http://nuc.lan
|
||||||
|
- Build: `npm run build`
|
||||||
18
nuc-portal/eslint.config.mjs
Normal file
18
nuc-portal/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
nuc-portal/next.config.ts
Normal file
7
nuc-portal/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
8904
nuc-portal/package-lock.json
generated
Normal file
8904
nuc-portal/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
nuc-portal/package.json
Normal file
34
nuc-portal/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "nuc-portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Self-hosted services dashboard for the NUC server",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.984.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.984.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"pg": "^8.18.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"swr": "^2.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
nuc-portal/postcss.config.mjs
Normal file
7
nuc-portal/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
53
nuc-portal/src/app/api/control/route.ts
Normal file
53
nuc-portal/src/app/api/control/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { controlResource, triggerDeploy } from '@/lib/coolify';
|
||||||
|
|
||||||
|
const VALID_ACTIONS = ['start', 'stop', 'restart', 'deploy'] as const;
|
||||||
|
const VALID_RESOURCE_TYPES = ['application', 'service', 'database'] as const;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { uuid, resourceType, action } = body as {
|
||||||
|
uuid: string;
|
||||||
|
resourceType: string;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!uuid || !action) {
|
||||||
|
return NextResponse.json({ error: 'Missing uuid or action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) {
|
||||||
|
return NextResponse.json({ error: `Invalid action: ${action}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy action
|
||||||
|
if (action === 'deploy') {
|
||||||
|
const result = await triggerDeploy(uuid);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, action, uuid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start/stop/restart
|
||||||
|
if (!resourceType || !VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) {
|
||||||
|
return NextResponse.json({ error: `Invalid resourceType: ${resourceType}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await controlResource(
|
||||||
|
uuid,
|
||||||
|
resourceType as 'application' | 'service' | 'database',
|
||||||
|
action as 'start' | 'stop' | 'restart'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: `Coolify returned ${result.status}` }, { status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, action, uuid });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Control error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to control service' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
128
nuc-portal/src/app/api/deployments/[uuid]/health/route.ts
Normal file
128
nuc-portal/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
nuc-portal/src/app/api/deployments/[uuid]/preview/route.tsx
Normal file
237
nuc-portal/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
nuc-portal/src/app/api/deployments/[uuid]/redeploy/route.ts
Normal file
41
nuc-portal/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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
nuc-portal/src/app/api/deployments/[uuid]/route.ts
Normal file
25
nuc-portal/src/app/api/deployments/[uuid]/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchDeploymentDetail } from '@/lib/coolify-db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { uuid } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deployment = await fetchDeploymentDetail(uuid);
|
||||||
|
if (!deployment) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(deployment, {
|
||||||
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching deployment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch deployment', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
nuc-portal/src/app/api/deployments/[uuid]/stats/route.ts
Normal file
89
nuc-portal/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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
nuc-portal/src/app/api/deployments/route.ts
Normal file
17
nuc-portal/src/app/api/deployments/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchDeployments } from '@/lib/coolify-db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const deployments = await fetchDeployments();
|
||||||
|
return NextResponse.json(deployments, {
|
||||||
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching deployments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch deployments', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
nuc-portal/src/app/api/discover/route.ts
Normal file
195
nuc-portal/src/app/api/discover/route.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { DiscoveredService, ServiceCategory } from '@/lib/services';
|
||||||
|
import { lookupService, lookupDatabase } from '@/lib/service-registry';
|
||||||
|
import { fetchResources, fetchAppDetail, fetchServiceDetail } from '@/lib/coolify';
|
||||||
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
const { nucHost } = serverConfig;
|
||||||
|
|
||||||
|
function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' {
|
||||||
|
if (status.startsWith('running')) return 'running';
|
||||||
|
if (status.startsWith('exited') || status === 'stopped') return 'stopped';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPort(fqdn: string | null, portsExposes: string | null, portsMappings: string | null): number {
|
||||||
|
if (fqdn) {
|
||||||
|
try {
|
||||||
|
const url = new URL(fqdn);
|
||||||
|
if (url.port) return parseInt(url.port, 10);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (portsMappings) {
|
||||||
|
const first = portsMappings.split(',')[0].trim();
|
||||||
|
const hostPort = first.split(':')[0];
|
||||||
|
if (hostPort) return parseInt(hostPort, 10);
|
||||||
|
}
|
||||||
|
if (portsExposes) {
|
||||||
|
const first = portsExposes.split(',')[0].trim();
|
||||||
|
return parseInt(first, 10);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPortFromServicePorts(ports: string | null): number {
|
||||||
|
if (!ports) return 0;
|
||||||
|
const first = ports.split(',')[0].trim();
|
||||||
|
const parts = first.split(':');
|
||||||
|
return parseInt(parts[0], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanServiceName(name: string): string {
|
||||||
|
return name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(fqdn: string | null, port: number): string {
|
||||||
|
if (fqdn) {
|
||||||
|
try {
|
||||||
|
const url = new URL(fqdn);
|
||||||
|
if (url.hostname.includes('sslip.io')) {
|
||||||
|
return `http://${nucHost}:${port || url.port || 80}`;
|
||||||
|
}
|
||||||
|
return fqdn;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
if (port > 0) return `http://${nucHost}:${port}`;
|
||||||
|
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() {
|
||||||
|
try {
|
||||||
|
const [resources, containerNames] = await Promise.all([
|
||||||
|
fetchResources(),
|
||||||
|
fetchContainerNames(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!resources) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch resources from Coolify', services: [] },
|
||||||
|
{ status: 502, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailPromises = resources.map(async (resource): Promise<DiscoveredService | null> => {
|
||||||
|
if (resource.type === 'application') {
|
||||||
|
const detail = await fetchAppDetail(resource.uuid);
|
||||||
|
if (!detail) return null;
|
||||||
|
|
||||||
|
const port = extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings);
|
||||||
|
const meta = lookupService(detail.name);
|
||||||
|
const url = buildUrl(detail.fqdn, port);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: detail.name,
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
icon: meta.icon,
|
||||||
|
category: meta.category as ServiceCategory,
|
||||||
|
description: detail.description || meta.description,
|
||||||
|
source: 'discovered',
|
||||||
|
fqdn: detail.fqdn || undefined,
|
||||||
|
resourceType: 'application',
|
||||||
|
uuid: resource.uuid,
|
||||||
|
coolifyStatus: resource.status,
|
||||||
|
container: findContainerName(resource.uuid, containerNames),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.type === 'service') {
|
||||||
|
const detail = await fetchServiceDetail(resource.uuid);
|
||||||
|
if (!detail) return null;
|
||||||
|
|
||||||
|
const app = detail.applications?.[0];
|
||||||
|
const cleanName = cleanServiceName(resource.name);
|
||||||
|
const meta = lookupService(cleanName);
|
||||||
|
|
||||||
|
let port = 0;
|
||||||
|
let fqdn: string | undefined;
|
||||||
|
|
||||||
|
if (app) {
|
||||||
|
port = extractPortFromServicePorts(app.ports) || extractPort(app.fqdn, null, null);
|
||||||
|
fqdn = app.fqdn && !app.fqdn.includes('sslip.io') ? app.fqdn : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(fqdn || null, port);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: cleanName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
icon: meta.icon,
|
||||||
|
category: meta.category as ServiceCategory,
|
||||||
|
description: meta.description,
|
||||||
|
source: 'discovered',
|
||||||
|
fqdn,
|
||||||
|
resourceType: 'service',
|
||||||
|
uuid: resource.uuid,
|
||||||
|
coolifyStatus: resource.status,
|
||||||
|
container: findContainerName(resource.uuid, containerNames),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.type.startsWith('standalone-')) {
|
||||||
|
const meta = lookupDatabase(resource.type, resource.name);
|
||||||
|
return {
|
||||||
|
name: resource.name,
|
||||||
|
url: `http://${nucHost}:8000`,
|
||||||
|
port: 0,
|
||||||
|
icon: meta.icon,
|
||||||
|
category: meta.category as ServiceCategory,
|
||||||
|
description: meta.description,
|
||||||
|
source: 'discovered',
|
||||||
|
resourceType: 'database',
|
||||||
|
uuid: resource.uuid,
|
||||||
|
coolifyStatus: resource.status,
|
||||||
|
container: findContainerName(resource.uuid, containerNames),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(detailPromises);
|
||||||
|
const discovered: DiscoveredService[] = [];
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
discovered.push(result.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered.sort((a, b) => {
|
||||||
|
const aRunning = a.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
|
const bRunning = b.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
|
if (aRunning !== bRunning) return aRunning - bRunning;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ services: discovered },
|
||||||
|
{ headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Discovery error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Discovery failed', services: [] },
|
||||||
|
{ status: 500, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
nuc-portal/src/app/api/events/route.ts
Normal file
41
nuc-portal/src/app/api/events/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eventManager } from '@/lib/event-manager';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const client = {
|
||||||
|
write: (data: string) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(data));
|
||||||
|
} catch {
|
||||||
|
eventManager.removeClient(client);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
try { controller.close(); } catch { /* already closed */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
eventManager.addClient(client);
|
||||||
|
|
||||||
|
// Clean up on disconnect
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
eventManager.removeClient(client);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
43
nuc-portal/src/app/api/health/route.ts
Normal file
43
nuc-portal/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { services } from '@/lib/services';
|
||||||
|
import { serverConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
const { nucHost } = serverConfig;
|
||||||
|
|
||||||
|
async function checkServiceHealth(port: number, timeout = 3000): Promise<'running' | 'stopped'> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
const response = await fetch(`http://${nucHost}:${port}`, {
|
||||||
|
method: 'HEAD',
|
||||||
|
signal: controller.signal,
|
||||||
|
}).catch(() => null);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response ? 'running' : 'stopped';
|
||||||
|
} catch {
|
||||||
|
return 'stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const healthStatus: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
services.map(async (service) => {
|
||||||
|
const status = await checkServiceHealth(service.port);
|
||||||
|
return { name: service.name, status };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
healthStatus[result.value.name] = result.value.status;
|
||||||
|
} else {
|
||||||
|
healthStatus[(result.reason as { name: string })?.name] = 'unknown';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(healthStatus, {
|
||||||
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
|
}
|
||||||
17
nuc-portal/src/app/api/metrics/route.ts
Normal file
17
nuc-portal/src/app/api/metrics/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchRangeMetrics } from '@/lib/prometheus';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const metrics = await fetchRangeMetrics();
|
||||||
|
return NextResponse.json(metrics, {
|
||||||
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metrics error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch metrics', cpu: [], ram: [], netRx: [], netTx: [], temp: [] },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
nuc-portal/src/app/api/stats/route.ts
Normal file
17
nuc-portal/src/app/api/stats/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchInstantStats } from '@/lib/prometheus';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const stats = await fetchInstantStats();
|
||||||
|
return NextResponse.json(stats, {
|
||||||
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch stats', details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
nuc-portal/src/app/deployments/[uuid]/page.tsx
Normal file
124
nuc-portal/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
nuc-portal/src/app/favicon.ico
Normal file
BIN
nuc-portal/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
99
nuc-portal/src/app/globals.css
Normal file
99
nuc-portal/src/app/globals.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/* Google Fonts */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Configure Tailwind v4 dark mode to use .dark class */
|
||||||
|
@custom-variant dark (&:is(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #F8FAFC;
|
||||||
|
--foreground: #1E293B;
|
||||||
|
|
||||||
|
/* Surface Colors */
|
||||||
|
--surface-page: #F8FAFC;
|
||||||
|
--surface-card: #FFFFFF;
|
||||||
|
--surface-muted: #F1F5F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-surface-page: var(--surface-page);
|
||||||
|
--color-surface-card: var(--surface-card);
|
||||||
|
--color-surface-muted: var(--surface-muted);
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-sans: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
.dark {
|
||||||
|
--background: #0C0A09;
|
||||||
|
--foreground: #FAFAF9;
|
||||||
|
--surface-page: #0C0A09;
|
||||||
|
--surface-card: #1C1917;
|
||||||
|
--surface-muted: #292524;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for dark mode */
|
||||||
|
.dark ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1C1917;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #44403C;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #57534E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utility */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for loading states */
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
24
nuc-portal/src/app/layout.tsx
Normal file
24
nuc-portal/src/app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "NUC Portal",
|
||||||
|
description: "Self-hosted services dashboard for the NUC server",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className="antialiased">
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
nuc-portal/src/app/page.tsx
Normal file
346
nuc-portal/src/app/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable, OverviewTab } from '@/components';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
|
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services';
|
||||||
|
|
||||||
|
type TabId = 'overview' | 'services' | 'bookmarks' | 'ai' | 'deployments' | 'settings';
|
||||||
|
|
||||||
|
const tabs: { id: TabId; label: string; icon: string }[] = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: 'layout' },
|
||||||
|
{ id: 'services', label: 'Services', icon: 'server' },
|
||||||
|
{ id: 'deployments', label: 'Deployments', icon: 'rocket' },
|
||||||
|
{ id: 'ai', label: 'AI', icon: 'bot' },
|
||||||
|
{ id: 'bookmarks', label: 'Bookmarks', icon: 'external-link' },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aiTools = [
|
||||||
|
{ name: 'Claude', url: 'https://claude.ai', icon: 'bot', description: 'Anthropic AI assistant' },
|
||||||
|
{ name: 'ChatGPT', url: 'https://chat.openai.com', icon: 'message-square', description: 'OpenAI chat assistant' },
|
||||||
|
{ name: 'Perplexity', url: 'https://perplexity.ai', icon: 'search', description: 'AI-powered search' },
|
||||||
|
{ name: 'Phind', url: 'https://phind.com', icon: 'code', description: 'AI for developers' },
|
||||||
|
{ name: 'Cursor', url: 'https://cursor.com', icon: 'terminal', description: 'AI-first code editor' },
|
||||||
|
{ name: 'v0', url: 'https://v0.dev', icon: 'layout', description: 'Vercel AI UI generator' },
|
||||||
|
{ name: 'Replicate', url: 'https://replicate.com', icon: 'cpu', description: 'ML model hosting' },
|
||||||
|
{ name: 'Hugging Face', url: 'https://huggingface.co', icon: 'smile', description: 'ML models & datasets' },
|
||||||
|
{ name: 'Together AI', url: 'https://together.ai', icon: 'users', description: 'Open model inference' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const {
|
||||||
|
filteredServices,
|
||||||
|
filteredBookmarks,
|
||||||
|
healthStatus,
|
||||||
|
searchQuery,
|
||||||
|
darkMode,
|
||||||
|
setDarkMode,
|
||||||
|
services,
|
||||||
|
deployments,
|
||||||
|
deploymentsLoading,
|
||||||
|
refreshDeployments,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
discoveredServices,
|
||||||
|
discoveryLoading,
|
||||||
|
discoveryError,
|
||||||
|
triggerDeploy,
|
||||||
|
connected,
|
||||||
|
} = usePortal();
|
||||||
|
|
||||||
|
// Group services by category
|
||||||
|
const servicesByCategory = categoryOrder.reduce((acc, category) => {
|
||||||
|
const categoryServices = filteredServices.filter(s => s.category === category);
|
||||||
|
if (categoryServices.length > 0) {
|
||||||
|
acc[category] = categoryServices;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<ServiceCategory, typeof filteredServices>);
|
||||||
|
|
||||||
|
// Group bookmarks by category
|
||||||
|
const bookmarksByCategory = bookmarkCategoryOrder.reduce((acc, category) => {
|
||||||
|
const categoryBookmarks = filteredBookmarks.filter(b => b.category === category);
|
||||||
|
if (categoryBookmarks.length > 0) {
|
||||||
|
acc[category] = categoryBookmarks;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<BookmarkCategory, typeof filteredBookmarks>);
|
||||||
|
|
||||||
|
const hasServices = Object.keys(servicesByCategory).length > 0;
|
||||||
|
const hasBookmarks = Object.keys(bookmarksByCategory).length > 0;
|
||||||
|
const noResults = searchQuery && !hasServices && !hasBookmarks;
|
||||||
|
|
||||||
|
// Count running services
|
||||||
|
const runningCount = services.filter(s => healthStatus[s.name] === 'running').length;
|
||||||
|
const totalServices = services.length;
|
||||||
|
|
||||||
|
// Discovery source label
|
||||||
|
const isDiscovered = discoveredServices.length > 0;
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'services':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 max-w-xl">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-slate-600 dark:text-stone-400">
|
||||||
|
{runningCount} of {totalServices} services running
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isDiscovered && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400">
|
||||||
|
Auto-discovered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{discoveryLoading && !isDiscovered && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-500 animate-pulse">
|
||||||
|
Discovering services...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{discoveryError && !isDiscovered && (
|
||||||
|
<span className="text-xs text-amber-500">
|
||||||
|
Using static list (Coolify unavailable)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noResults && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-slate-500 dark:text-stone-500">
|
||||||
|
No services found for "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasServices && (
|
||||||
|
<div>
|
||||||
|
{Object.entries(servicesByCategory).map(([category, services]) => (
|
||||||
|
<CategorySection
|
||||||
|
key={category}
|
||||||
|
title={categoryLabels[category as ServiceCategory]}
|
||||||
|
count={services.length}
|
||||||
|
columns={3}
|
||||||
|
>
|
||||||
|
{services.map(service => (
|
||||||
|
<ServiceCard
|
||||||
|
key={service.name}
|
||||||
|
service={service}
|
||||||
|
status={healthStatus[service.name] || 'unknown'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CategorySection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'bookmarks':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 max-w-xl">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchQuery && !hasBookmarks && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-slate-500 dark:text-stone-500">
|
||||||
|
No bookmarks found for "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasBookmarks && (
|
||||||
|
<div>
|
||||||
|
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => (
|
||||||
|
<CategorySection
|
||||||
|
key={category}
|
||||||
|
title={bookmarkCategoryLabels[category as BookmarkCategory]}
|
||||||
|
count={bookmarks.length}
|
||||||
|
columns={4}
|
||||||
|
>
|
||||||
|
{bookmarks.map(bookmark => (
|
||||||
|
<BookmarkCard
|
||||||
|
key={bookmark.name}
|
||||||
|
bookmark={bookmark}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CategorySection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'ai':
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
|
||||||
|
AI Tools & Platforms
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
|
Quick access to AI assistants and platforms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{aiTools.map(tool => (
|
||||||
|
<a
|
||||||
|
key={tool.name}
|
||||||
|
href={tool.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm hover:shadow-md hover:border-slate-200 dark:hover:border-stone-600/50 transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800">
|
||||||
|
<Icon name={tool.icon} size={20} className="text-slate-600 dark:text-stone-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-stone-100">{tool.name}</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-stone-500 truncate">{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'overview':
|
||||||
|
return <OverviewTab />;
|
||||||
|
|
||||||
|
case 'deployments':
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
|
||||||
|
Deployments
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
|
All deployments across Coolify applications
|
||||||
|
{connected && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 text-emerald-500">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DeploymentsTable
|
||||||
|
deployments={deployments}
|
||||||
|
isLoading={deploymentsLoading}
|
||||||
|
onRefresh={refreshDeployments}
|
||||||
|
onDeploy={triggerDeploy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'settings':
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100 mb-6">
|
||||||
|
Appearance
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-slate-100 dark:border-stone-800">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-stone-100">Dark Mode</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
|
Use dark theme for the portal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
|
darkMode ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-stone-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||||
|
darkMode ? 'translate-x-6' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100 mt-8 mb-6">
|
||||||
|
About
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Version</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100">1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Server IP</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100 font-mono">{clientConfig.nucHost}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Services</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100">{totalServices}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Discovery</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100">
|
||||||
|
{isDiscovered ? 'Coolify API' : 'Static'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Connection</span>
|
||||||
|
<span className={connected ? 'text-emerald-500' : 'text-red-500'}>
|
||||||
|
{connected ? 'SSE Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
||||||
|
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-100 dark:border-stone-800">
|
||||||
|
<a
|
||||||
|
href={`http://${clientConfig.nucHost}:3030/alezmad/nuc-portal`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
|
||||||
|
>
|
||||||
|
<Icon name="git-branch" size={16} />
|
||||||
|
View on Gitea
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-stone-950">
|
||||||
|
<Header activeTab={activeTab} onTabChange={(tab) => setActiveTab(tab as TabId)} tabs={tabs} />
|
||||||
|
|
||||||
|
<main className={`mx-auto px-4 sm:px-6 py-8 overflow-hidden ${
|
||||||
|
activeTab === 'deployments' ? 'max-w-[1600px]' : 'max-w-6xl'
|
||||||
|
}`}>
|
||||||
|
|
||||||
|
{renderTabContent()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="text-center py-8 text-sm text-slate-400 dark:text-stone-600">
|
||||||
|
<span>NUC Portal</span>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
<span>{clientConfig.nucHost}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
nuc-portal/src/app/providers.tsx
Normal file
7
nuc-portal/src/app/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PortalProvider } from '@/lib/PortalContext';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <PortalProvider>{children}</PortalProvider>;
|
||||||
|
}
|
||||||
47
nuc-portal/src/components/BookmarkCard.tsx
Normal file
47
nuc-portal/src/components/BookmarkCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Bookmark } from '@/lib/services';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
|
interface BookmarkCardProps {
|
||||||
|
bookmark: Bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookmarkCard({ bookmark }: BookmarkCardProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={bookmark.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex items-center gap-3 p-3 bg-white dark:bg-stone-900 rounded-lg border border-slate-100 dark:border-stone-700/50 shadow-sm hover:border-slate-200 dark:hover:border-stone-600/50 hover:shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-md bg-slate-100 dark:bg-stone-800 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
|
||||||
|
<Icon
|
||||||
|
name={bookmark.icon}
|
||||||
|
size={16}
|
||||||
|
className="text-slate-500 dark:text-stone-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-sm text-slate-900 dark:text-stone-100 truncate">
|
||||||
|
{bookmark.name}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
name="external-link"
|
||||||
|
size={12}
|
||||||
|
className="text-slate-400 dark:text-stone-600 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{bookmark.description && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-stone-500 truncate">
|
||||||
|
{bookmark.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
nuc-portal/src/components/CategorySection.tsx
Normal file
37
nuc-portal/src/components/CategorySection.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
children: ReactNode;
|
||||||
|
columns?: 2 | 3 | 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySection({ title, count, children, columns = 3 }: CategorySectionProps) {
|
||||||
|
const gridCols = {
|
||||||
|
2: 'grid-cols-1 sm:grid-cols-2',
|
||||||
|
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||||
|
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-slate-200 dark:bg-stone-800 text-slate-600 dark:text-stone-400">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className={`grid ${gridCols[columns]} gap-4`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1073
nuc-portal/src/components/DeploymentDashboard.tsx
Normal file
1073
nuc-portal/src/components/DeploymentDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
226
nuc-portal/src/components/DeploymentLogs.tsx
Normal file
226
nuc-portal/src/components/DeploymentLogs.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
|
interface DeploymentLogsProps {
|
||||||
|
deploymentUuid: string;
|
||||||
|
status: DeploymentStatus;
|
||||||
|
initialLogs?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogLineStyle(log: DeploymentLog): string {
|
||||||
|
const output = log.output.toLowerCase();
|
||||||
|
if (log.type === 'stderr' || output.includes('error') || output.includes('failed')) {
|
||||||
|
return 'text-red-500 dark:text-red-400';
|
||||||
|
}
|
||||||
|
if (output.includes('warning') || output.includes('warn')) {
|
||||||
|
return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
}
|
||||||
|
if (output.includes('success') || output.includes('finished') || output.includes('done') || output.includes('✓')) {
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
}
|
||||||
|
if (output.startsWith('---') || output.startsWith('===') || output.startsWith('###')) {
|
||||||
|
return 'text-cyan-600 dark:text-cyan-400 font-semibold';
|
||||||
|
}
|
||||||
|
if (output.startsWith('$') || output.startsWith('>') || output.startsWith('#')) {
|
||||||
|
return 'text-purple-600 dark:text-purple-400';
|
||||||
|
}
|
||||||
|
return 'text-slate-600 dark:text-stone-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) {
|
||||||
|
const { activeDeployLogs } = usePortal();
|
||||||
|
const [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
|
||||||
|
const isActive = status === 'in_progress' || status === 'queued';
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
// Update logs from SSE active deploy logs
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const match = activeDeployLogs.find(l => l.uuid === deploymentUuid);
|
||||||
|
if (match?.logs) {
|
||||||
|
setLogs(parseDeploymentLogs(match.logs));
|
||||||
|
}
|
||||||
|
}, [activeDeployLogs, deploymentUuid, isActive]);
|
||||||
|
|
||||||
|
// Fetch logs on first render if none provided
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/deployments/${deploymentUuid}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setLogs(parseDeploymentLogs(data.logs));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch logs:', error);
|
||||||
|
}
|
||||||
|
}, [deploymentUuid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLogs && !isActive) {
|
||||||
|
fetchLogs();
|
||||||
|
}
|
||||||
|
}, [initialLogs, isActive, fetchLogs]);
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
|
setAutoScroll(scrollHeight - scrollTop - clientHeight < 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
const text = logs.map((log) => log.output).join('\n');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expanded modal view
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-6xl h-[80vh] bg-slate-900 rounded-xl shadow-2xl flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
|
<span className="text-sm font-medium text-slate-200">Build Logs - {deploymentUuid.substring(0, 12)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={14} />
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-auto font-mono text-xs p-4 space-y-0.5 bg-slate-950"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-slate-500 text-center py-8">
|
||||||
|
{isActive ? 'Waiting for logs...' : 'No logs available'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
||||||
|
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">{index + 1}</span>
|
||||||
|
{log.timestamp && (
|
||||||
|
<span className="text-slate-500 mr-3 select-none shrink-0">{log.timestamp}</span>
|
||||||
|
)}
|
||||||
|
<span className="whitespace-pre-wrap break-all">{log.output}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 border-t border-slate-700 text-xs text-slate-500">
|
||||||
|
{logs.length} lines
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline view
|
||||||
|
return (
|
||||||
|
<div className="border-t border-slate-200 dark:border-stone-800 bg-slate-900 dark:bg-stone-950">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-700 dark:border-stone-800">
|
||||||
|
<span className="text-sm font-medium text-slate-300">Build Logs</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(true); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="maximize-2" size={14} />
|
||||||
|
Expand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-64 overflow-y-auto overflow-x-hidden font-mono text-xs p-4 space-y-0.5"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-slate-500 text-center py-8">
|
||||||
|
{isActive ? 'Waiting for logs...' : 'No logs available'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
||||||
|
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">{index + 1}</span>
|
||||||
|
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
{!autoScroll && logs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAutoScroll(true);
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute bottom-4 right-4 px-3 py-1.5 bg-slate-700 text-slate-200 text-xs rounded-full shadow-lg hover:bg-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Scroll to bottom
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
nuc-portal/src/components/DeploymentSkeleton.tsx
Normal file
260
nuc-portal/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
456
nuc-portal/src/components/DeploymentsTable.tsx
Normal file
456
nuc-portal/src/components/DeploymentsTable.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect, Fragment, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
flexRender,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
ExpandedState,
|
||||||
|
createColumnHelper,
|
||||||
|
Row,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Deployment,
|
||||||
|
DeploymentStatus,
|
||||||
|
STATUS_COLORS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
formatDuration,
|
||||||
|
formatRelativeTime,
|
||||||
|
truncateCommitMessage,
|
||||||
|
} from '@/lib/deployments';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
import { DeploymentLogs } from './DeploymentLogs';
|
||||||
|
|
||||||
|
interface DeploymentsTableProps {
|
||||||
|
deployments: Deployment[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onDeploy?: (uuid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Deployment>();
|
||||||
|
|
||||||
|
const StatusDot = ({ status }: { status: DeploymentStatus }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full ${STATUS_COLORS[status]} ${
|
||||||
|
status === 'in_progress' ? 'animate-pulse' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }: { status: DeploymentStatus }) => (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<StatusDot status={status} />
|
||||||
|
<span className="text-slate-700 dark:text-stone-300">{STATUS_LABELS[status]}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
function LiveDuration({ createdAt }: { createdAt: string }) {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = new Date(createdAt).getTime();
|
||||||
|
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
|
||||||
|
tick();
|
||||||
|
const interval = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [createdAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-orange-500 dark:text-orange-400 text-sm tabular-nums">
|
||||||
|
{formatDuration(elapsed)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: 'created_at', desc: true },
|
||||||
|
]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||||
|
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('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 names = new Set(deployments.map((d) => d.application_name));
|
||||||
|
return Array.from(names).sort();
|
||||||
|
}, [deployments]);
|
||||||
|
|
||||||
|
const filteredDeployments = useMemo(() => {
|
||||||
|
return deployments.filter((d) => {
|
||||||
|
if (statusFilter !== 'all' && d.status !== statusFilter) return false;
|
||||||
|
if (appFilter !== 'all' && d.application_name !== appFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [deployments, statusFilter, appFilter]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.accessor('deployment_uuid', {
|
||||||
|
header: 'Deployment',
|
||||||
|
cell: ({ row, getValue }) => (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
title={row.getIsExpanded() ? 'Collapse logs' : 'Expand logs'}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={row.getIsExpanded() ? 'chevron-down' : 'chevron-right'}
|
||||||
|
size={14}
|
||||||
|
className="transition-transform"
|
||||||
|
/>
|
||||||
|
<span>{getValue().substring(0, 9)}</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('is_current', {
|
||||||
|
header: 'Environment',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded bg-slate-100 dark:bg-stone-800 text-slate-700 dark:text-stone-300">
|
||||||
|
Production
|
||||||
|
</span>
|
||||||
|
{getValue() && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-cyan-600 dark:text-cyan-400">
|
||||||
|
<Icon name="check" size={12} />
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('duration', {
|
||||||
|
header: 'Duration',
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
if (row.original.status === 'in_progress') {
|
||||||
|
return <LiveDuration createdAt={row.original.created_at} />;
|
||||||
|
}
|
||||||
|
const duration = getValue();
|
||||||
|
return (
|
||||||
|
<span className="text-slate-500 dark:text-stone-400 text-sm">
|
||||||
|
{duration ? formatDuration(duration) : '-'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('status', {
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ getValue }) => <StatusBadge status={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('application_name', {
|
||||||
|
header: 'Application',
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<a
|
||||||
|
href={`${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${row.original.application_uuid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center rounded bg-slate-100 dark:bg-stone-800">
|
||||||
|
<Icon name="box" size={12} className="text-slate-500 dark:text-stone-400" />
|
||||||
|
</span>
|
||||||
|
{getValue()}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('git_branch', {
|
||||||
|
header: 'Source',
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-slate-400 dark:text-stone-500">
|
||||||
|
<Icon name="git-branch" size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-700 dark:text-stone-300">{getValue() || 'main'}</span>
|
||||||
|
{row.original.git_commit_sha && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-400 dark:text-stone-600 font-mono">
|
||||||
|
{row.original.git_commit_sha.substring(0, 7)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-stone-500 truncate max-w-[200px]">
|
||||||
|
{truncateCommitMessage(row.original.commit_message || '')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('created_at', {
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-400">
|
||||||
|
<span>{formatRelativeTime(getValue())}</span>
|
||||||
|
<span className="text-slate-400 dark:text-stone-600">
|
||||||
|
{row.original.is_webhook ? (
|
||||||
|
<Icon name="webhook" size={13} className="text-violet-500" />
|
||||||
|
) : (
|
||||||
|
<Icon name="terminal" size={13} className="text-slate-400 dark:text-stone-600" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{onDeploy && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeploy(row.original.application_uuid);
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 dark:text-stone-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
|
||||||
|
title="Redeploy"
|
||||||
|
>
|
||||||
|
<Icon name="rocket" size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{row.original.application_fqdn && (
|
||||||
|
<a
|
||||||
|
href={row.original.application_fqdn}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 text-cyan-500 dark:text-cyan-400 hover:text-cyan-600 dark:hover:text-cyan-300 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Open deployed site"
|
||||||
|
>
|
||||||
|
<Icon name="globe" size={16} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${row.original.application_uuid}/deployment/${row.original.deployment_uuid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 text-slate-400 dark:text-stone-500 hover:text-slate-600 dark:hover:text-stone-300 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="View in Coolify"
|
||||||
|
>
|
||||||
|
<Icon name="coolify" size={16} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[onDeploy, toggleExpand]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredDeployments,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderExpandedRow = (row: Row<Deployment>) => (
|
||||||
|
<DeploymentLogs
|
||||||
|
deploymentUuid={row.original.deployment_uuid}
|
||||||
|
status={row.original.status}
|
||||||
|
initialLogs={row.original.logs}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (appFilter !== 'all' ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={appFilter}
|
||||||
|
onChange={(e) => setAppFilter(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm bg-white dark:bg-stone-900 border border-slate-200 dark:border-stone-700 rounded-lg text-slate-700 dark:text-stone-300 focus:outline-none focus:border-slate-400 dark:focus:border-stone-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Applications</option>
|
||||||
|
{applicationNames.map((name) => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as DeploymentStatus | 'all')}
|
||||||
|
className="px-3 py-1.5 text-sm bg-white dark:bg-stone-900 border border-slate-200 dark:border-stone-700 rounded-lg text-slate-700 dark:text-stone-300 focus:outline-none focus:border-slate-400 dark:focus:border-stone-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="finished">Ready</option>
|
||||||
|
<option value="in_progress">Building</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="queued">Queued</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setStatusFilter('all'); setAppFilter('all'); }}
|
||||||
|
className="text-xs text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
|
{filteredDeployments.length} deployments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 overflow-x-auto shadow-sm">
|
||||||
|
<table className="w-full min-w-[900px]">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id} className="border-b border-slate-200 dark:border-stone-800">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-stone-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 ${
|
||||||
|
header.column.getCanSort()
|
||||||
|
? 'cursor-pointer hover:text-slate-700 dark:hover:text-stone-300'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{header.column.getIsSorted() === 'asc' && <Icon name="chevron-up" size={14} />}
|
||||||
|
{header.column.getIsSorted() === 'desc' && <Icon name="chevron-down" size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading && deployments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-slate-500 dark:text-stone-500">
|
||||||
|
<Icon name="refresh-cw" size={16} className="animate-spin" />
|
||||||
|
Loading deployments...
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredDeployments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-4 py-12 text-center text-slate-500 dark:text-stone-500">
|
||||||
|
No deployments found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<tr
|
||||||
|
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' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleRowClick(row.original.deployment_uuid)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-4 py-3">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{row.getIsExpanded() && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="p-0 max-w-0">
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{renderExpandedRow(row)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{filteredDeployments.length > 25 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-stone-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-stone-500">Rows per page:</span>
|
||||||
|
<select
|
||||||
|
value={table.getState().pagination.pageSize}
|
||||||
|
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
||||||
|
className="px-2 py-1 text-sm bg-slate-100 dark:bg-stone-800 border border-slate-200 dark:border-stone-700 rounded text-slate-700 dark:text-stone-300 focus:outline-none"
|
||||||
|
>
|
||||||
|
{[25, 50, 100].map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
className="p-1 text-slate-400 dark:text-stone-400 hover:text-slate-600 dark:hover:text-stone-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
className="p-1 text-slate-400 dark:text-stone-400 hover:text-slate-600 dark:hover:text-stone-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
nuc-portal/src/components/Header.tsx
Normal file
98
nuc-portal/src/components/Header.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
import { VitalsBar } from './VitalsBar';
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
tabs: Tab[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
|
||||||
|
const { darkMode, setDarkMode, connected } = usePortal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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-6xl mx-auto px-4 sm:px-6">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="py-4 flex items-center justify-between">
|
||||||
|
{/* Logo / Title */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 flex items-center justify-center rounded-lg bg-gradient-to-br from-slate-700 to-slate-900 dark:from-stone-600 dark:to-stone-800">
|
||||||
|
<Icon name="server" size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-lg text-slate-900 dark:text-stone-100">
|
||||||
|
NUC Portal
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-stone-500">
|
||||||
|
{clientConfig.nucHost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* SSE connection indicator */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs"
|
||||||
|
title={connected ? 'Real-time connection active' : 'Reconnecting...'}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
connected
|
||||||
|
? 'bg-emerald-500 animate-pulse'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`} />
|
||||||
|
<span className={connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}>
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dark mode toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="p-2 rounded-lg bg-slate-100 dark:bg-stone-900 border border-slate-200 dark:border-stone-800 hover:bg-slate-200 dark:hover:bg-stone-800 transition-colors"
|
||||||
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={darkMode ? 'sun' : 'moon'}
|
||||||
|
size={18}
|
||||||
|
className="text-slate-600 dark:text-stone-400"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vitals */}
|
||||||
|
<VitalsBar />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<nav className="flex gap-1 mt-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-none cursor-pointer rounded-t-lg transition-all whitespace-nowrap ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-slate-100 dark:bg-stone-800 text-slate-900 dark:text-stone-50'
|
||||||
|
: 'bg-transparent text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={tab.icon} size={16} />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
nuc-portal/src/components/Icons.tsx
Normal file
195
nuc-portal/src/components/Icons.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Simple SVG icon components based on Lucide icons
|
||||||
|
// Using inline SVGs to avoid external dependencies
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIcon = (paths: string) => {
|
||||||
|
return function Icon({ className = '', size = 24 }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
dangerouslySetInnerHTML={{ __html: paths }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const icons: Record<string, React.ComponentType<IconProps>> = {
|
||||||
|
// Infrastructure
|
||||||
|
'server': createIcon('<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>'),
|
||||||
|
'scroll-text': createIcon('<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/>'),
|
||||||
|
'monitor': createIcon('<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>'),
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
'workflow': createIcon('<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>'),
|
||||||
|
|
||||||
|
// Development
|
||||||
|
'git-branch': createIcon('<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>'),
|
||||||
|
'database': createIcon('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>'),
|
||||||
|
'table': createIcon('<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>'),
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
'book-open': createIcon('<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>'),
|
||||||
|
'grid-3x3': createIcon('<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/>'),
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
'folder': createIcon('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
|
||||||
|
'hard-drive': createIcon('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
|
||||||
|
'archive': createIcon('<rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>'),
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
'activity': createIcon('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'),
|
||||||
|
'bell': createIcon('<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>'),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
'lock': createIcon('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
|
||||||
|
'shield': createIcon('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>'),
|
||||||
|
|
||||||
|
// Developer tools
|
||||||
|
'book': createIcon('<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/>'),
|
||||||
|
'check-circle': createIcon('<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>'),
|
||||||
|
'brackets': createIcon('<path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/>'),
|
||||||
|
'package': createIcon('<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||||
|
'arrow-right-left': createIcon('<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>'),
|
||||||
|
|
||||||
|
// AI tools
|
||||||
|
'bot': createIcon('<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>'),
|
||||||
|
'message-square': createIcon('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
|
||||||
|
'search': createIcon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
|
||||||
|
'code': createIcon('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>'),
|
||||||
|
'terminal': createIcon('<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>'),
|
||||||
|
|
||||||
|
// AI platforms
|
||||||
|
'layout': createIcon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="9" y2="9"/><line x1="9" x2="9" y1="21" y2="9"/>'),
|
||||||
|
'cpu': createIcon('<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>'),
|
||||||
|
'smile': createIcon('<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>'),
|
||||||
|
'users': createIcon('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
'pencil': createIcon('<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>'),
|
||||||
|
'braces': createIcon('<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>'),
|
||||||
|
'image': createIcon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>'),
|
||||||
|
'image-down': createIcon('<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21"/><path d="m14 19.5 3 3v-6"/><path d="m17 22.5 3-3"/><circle cx="9" cy="9" r="2"/>'),
|
||||||
|
'file-image': createIcon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><circle cx="10" cy="12" r="2"/><path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'),
|
||||||
|
|
||||||
|
// Design
|
||||||
|
'figma': createIcon('<path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z"/><path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z"/><path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z"/><path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z"/><path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z"/>'),
|
||||||
|
'palette': createIcon('<circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>'),
|
||||||
|
'shapes': createIcon('<path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/>'),
|
||||||
|
'circle': createIcon('<circle cx="12" cy="12" r="10"/>'),
|
||||||
|
|
||||||
|
// Learning
|
||||||
|
'graduation-cap': createIcon('<path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/>'),
|
||||||
|
'globe': createIcon('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'),
|
||||||
|
|
||||||
|
// Productivity
|
||||||
|
'list-todo': createIcon('<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>'),
|
||||||
|
'notebook': createIcon('<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><rect width="16" height="20" x="4" y="2" rx="2"/><path d="M16 2v20"/>'),
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
'sun': createIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>'),
|
||||||
|
'moon': createIcon('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'),
|
||||||
|
'external-link': createIcon('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'),
|
||||||
|
'refresh-cw': createIcon('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>'),
|
||||||
|
'x': createIcon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
|
||||||
|
'maximize-2': createIcon('<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>'),
|
||||||
|
'settings': createIcon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
|
||||||
|
'loader': createIcon('<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>'),
|
||||||
|
'play': createIcon('<polygon points="6 3 20 12 6 21 6 3"/>'),
|
||||||
|
'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"/>'),
|
||||||
|
|
||||||
|
// 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
|
||||||
|
'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'),
|
||||||
|
'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'),
|
||||||
|
'chevron-up': createIcon('<path d="m18 15-6-6-6 6"/>'),
|
||||||
|
'chevron-down': createIcon('<path d="m6 9 6 6 6-6"/>'),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'check': createIcon('<path d="M20 6 9 17l-5-5"/>'),
|
||||||
|
'copy': createIcon('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>'),
|
||||||
|
'box': createIcon('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||||
|
'rocket': createIcon('<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'),
|
||||||
|
|
||||||
|
// Coolify logo icon
|
||||||
|
'coolify': function CoolifyIcon({ className = '', size = 24 }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 352 352"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M63.996 64V0h256v64Zm0 192h-64V64h64Zm0 0h256v64h-256Zm32-160V71.067h231.066V32h24.934v64zm0 0v152.533H71.063V96ZM56.93 263.066V288H31.997v-24.934ZM351.996 352h-256v-24.934h231.066V288h24.934z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// WhyRating brand logo icon
|
||||||
|
'whyrating': function WhyRatingIcon({ className = '', size = 24 }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="60,15 71.5,42 101,46 79.5,66 85,95 60,82 35,95 40.5,66 19,46 48.5,42"
|
||||||
|
fill="#FBBC05"
|
||||||
|
stroke="#FBBC05"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<g>
|
||||||
|
<circle cx="60" cy="62" r="27" fill="#1E293B"/>
|
||||||
|
<line x1="83" y1="81" x2="95" y2="91" stroke="#1E293B" strokeWidth="9" strokeLinecap="round"/>
|
||||||
|
<circle cx="60" cy="62" r="21" fill="#FEF3C7"/>
|
||||||
|
<rect x="68" y="44" width="11" height="18" rx="1.5" fill="#15803D"/>
|
||||||
|
<clipPath id="whyrating-lens">
|
||||||
|
<circle cx="60" cy="62" r="21"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clipPath="url(#whyrating-lens)">
|
||||||
|
<rect x="42" y="58" width="11" height="35" rx="1.5" fill="#86EFAC"/>
|
||||||
|
<rect x="55" y="51" width="11" height="42" rx="1.5" fill="#22C55E"/>
|
||||||
|
<rect x="68" y="44" width="11" height="49" rx="1.5" fill="#15803D"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Icon({ name, className, size }: { name: string; className?: string; size?: number }) {
|
||||||
|
const IconComponent = icons[name];
|
||||||
|
if (!IconComponent) {
|
||||||
|
return <span className={className}>?</span>;
|
||||||
|
}
|
||||||
|
return <IconComponent className={className} size={size} />;
|
||||||
|
}
|
||||||
443
nuc-portal/src/components/OverviewTab.tsx
Normal file
443
nuc-portal/src/components/OverviewTab.tsx
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
import { SystemTrends } from './SystemTrends';
|
||||||
|
import { formatUptime } from '@/lib/stats';
|
||||||
|
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
|
||||||
|
import type { DeploymentStatus } from '@/lib/deployments';
|
||||||
|
import type { HealthStatus } from '@/lib/PortalContext';
|
||||||
|
import { getCoolifyUrl } from '@/lib/services';
|
||||||
|
import type { Service, DiscoveredService } from '@/lib/services';
|
||||||
|
|
||||||
|
function isDiscoveredService(s: Service): s is DiscoveredService {
|
||||||
|
return 'uuid' in s && 'resourceType' in s;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDef {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
apps: { name: string; url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects: ProjectDef[] = [
|
||||||
|
{
|
||||||
|
name: 'WhyRating',
|
||||||
|
icon: 'whyrating',
|
||||||
|
apps: [
|
||||||
|
{ name: 'Hub', url: 'http://whyrating.nuc.lan' },
|
||||||
|
{ name: 'WhyOps', url: 'http://whyops.nuc.lan' },
|
||||||
|
{ name: 'Brand', url: 'http://brand.nuc.lan' },
|
||||||
|
{ name: 'Templates', url: 'http://templates.nuc.lan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Knosia',
|
||||||
|
icon: 'book-open',
|
||||||
|
apps: [
|
||||||
|
{ name: 'App', url: 'http://knosia.nuc.lan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function SectionHeader({ icon, title, badge, tabTarget, onNavigate }: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
tabTarget?: string;
|
||||||
|
onNavigate?: (tab: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 flex items-center gap-2">
|
||||||
|
<Icon name={icon} size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
{title}
|
||||||
|
{badge}
|
||||||
|
</h2>
|
||||||
|
{tabTarget && onNavigate && (
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(tabTarget)}
|
||||||
|
className="text-xs text-slate-400 dark:text-stone-600 hover:text-slate-600 dark:hover:text-stone-400 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<Icon name="chevron-right" size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project, services, healthStatus }: {
|
||||||
|
project: ProjectDef;
|
||||||
|
services: Service[];
|
||||||
|
healthStatus: Record<string, HealthStatus>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
|
<Icon name={project.icon} size={16} />
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{project.apps.map(app => {
|
||||||
|
const svc = services.find(s => s.url === app.url);
|
||||||
|
const status = svc ? healthStatus[svc.name] : undefined;
|
||||||
|
const dot = status === 'running' ? 'bg-emerald-500' : status === 'stopped' ? 'bg-red-500' : 'bg-slate-300 dark:bg-stone-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={app.url}
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2.5 p-2 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dot}`} />
|
||||||
|
<span className="text-sm text-slate-700 dark:text-stone-300 flex-1 truncate">{app.name}</span>
|
||||||
|
<Icon name="external-link" size={12} className="text-slate-300 dark:text-stone-700 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab() {
|
||||||
|
const {
|
||||||
|
systemStats,
|
||||||
|
services,
|
||||||
|
healthStatus,
|
||||||
|
deployments,
|
||||||
|
deploymentsLoading,
|
||||||
|
discoveredServices,
|
||||||
|
setActiveTab,
|
||||||
|
refreshDiscover,
|
||||||
|
} = usePortal();
|
||||||
|
|
||||||
|
const [controlling, setControlling] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const controlService = useCallback(async (s: Service, action: 'start' | 'stop' | 'restart') => {
|
||||||
|
if (!isDiscoveredService(s)) return;
|
||||||
|
setControlling(prev => ({ ...prev, [s.uuid]: true }));
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/control', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uuid: s.uuid, resourceType: s.resourceType, action }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setTimeout(() => refreshDiscover(), 3000);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setControlling(prev => ({ ...prev, [s.uuid]: false }));
|
||||||
|
}
|
||||||
|
}, [refreshDiscover]);
|
||||||
|
|
||||||
|
// Build quick links dynamically from discovered services
|
||||||
|
const quickLinkDefs = [
|
||||||
|
{ key: 'coolify', icon: 'coolify', desc: 'Service manager' },
|
||||||
|
{ key: 'dozzle', icon: 'scroll-text', desc: 'Container logs' },
|
||||||
|
{ key: 'uptime kuma', icon: 'activity', desc: 'Monitoring' },
|
||||||
|
{ key: 'ntfy', icon: 'bell', desc: 'Notifications' },
|
||||||
|
{ key: 'gitea', icon: 'git-branch', desc: 'Git hosting' },
|
||||||
|
{ key: 'adminer', icon: 'database', desc: 'DB admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickLinks = quickLinkDefs.map(def => {
|
||||||
|
const svc = services.find(s => s.name.toLowerCase().includes(def.key));
|
||||||
|
return {
|
||||||
|
name: svc?.name || def.key.charAt(0).toUpperCase() + def.key.slice(1),
|
||||||
|
url: svc?.url || `http://${clientConfig.nucHost}`,
|
||||||
|
icon: svc?.icon || def.icon,
|
||||||
|
desc: def.desc,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningServices = services.filter(s => healthStatus[s.name] === 'running');
|
||||||
|
const stoppedServices = services.filter(s => healthStatus[s.name] === 'stopped');
|
||||||
|
const unknownServices = services.filter(s => {
|
||||||
|
const st = healthStatus[s.name];
|
||||||
|
return st !== 'running' && st !== 'stopped';
|
||||||
|
});
|
||||||
|
const totalCount = services.length;
|
||||||
|
|
||||||
|
const recentDeployments = deployments.slice(0, 5);
|
||||||
|
const isDiscovered = discoveredServices.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 max-w-6xl">
|
||||||
|
|
||||||
|
{/* Row 1: System Trends (full-width hero) */}
|
||||||
|
<SystemTrends
|
||||||
|
uptimeLabel={systemStats ? formatUptime(systemStats.uptime_seconds) : undefined}
|
||||||
|
loadAvg={systemStats?.load_avg}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Row 2: Services | Deployments */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{/* Services */}
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
<SectionHeader
|
||||||
|
icon="server"
|
||||||
|
title="Services"
|
||||||
|
tabTarget="services"
|
||||||
|
onNavigate={setActiveTab}
|
||||||
|
badge={isDiscovered ? (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-normal">
|
||||||
|
Auto-discovered
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-3 mb-3">
|
||||||
|
<span className="text-3xl font-bold text-slate-900 dark:text-stone-100 tabular-nums">{runningServices.length}</span>
|
||||||
|
<span className="text-sm text-slate-500 dark:text-stone-500">of {totalCount} running</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stoppedServices.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-[11px] font-medium text-red-500/70 dark:text-red-400/70 uppercase tracking-wider mb-1.5">Stopped</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{stoppedServices.map(s => {
|
||||||
|
const uuid = isDiscoveredService(s) ? s.uuid : null;
|
||||||
|
const loading = uuid ? controlling[uuid] : false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={s.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-100 dark:border-red-800/30"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
|
||||||
|
<a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:underline">{s.name}</a>
|
||||||
|
{isDiscoveredService(s) && (
|
||||||
|
<a
|
||||||
|
href={getCoolifyUrl(s)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Manage ${s.name} in Coolify`}
|
||||||
|
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={11} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{uuid && (
|
||||||
|
<button
|
||||||
|
onClick={() => controlService(s, 'start')}
|
||||||
|
disabled={loading}
|
||||||
|
title={`Start ${s.name}`}
|
||||||
|
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-800/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Icon name="loader" size={11} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon name="play" size={11} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unknownServices.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-[11px] font-medium text-slate-400/70 dark:text-stone-500/70 uppercase tracking-wider mb-1.5">Unknown</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{unknownServices.map(s => {
|
||||||
|
const uuid = isDiscoveredService(s) ? s.uuid : null;
|
||||||
|
const loading = uuid ? controlling[uuid] : false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={s.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-slate-50 dark:bg-stone-800/50 text-slate-500 dark:text-stone-500 border border-slate-200 dark:border-stone-700/50"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-stone-600 flex-shrink-0" />
|
||||||
|
<a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:underline">{s.name}</a>
|
||||||
|
{isDiscoveredService(s) && (
|
||||||
|
<a
|
||||||
|
href={getCoolifyUrl(s)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Manage ${s.name} in Coolify`}
|
||||||
|
className="p-0.5 rounded hover:bg-slate-200 dark:hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={11} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{uuid && (
|
||||||
|
<button
|
||||||
|
onClick={() => controlService(s, 'start')}
|
||||||
|
disabled={loading}
|
||||||
|
title={`Start ${s.name}`}
|
||||||
|
className="p-0.5 rounded hover:bg-slate-200 dark:hover:bg-stone-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Icon name="loader" size={11} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon name="play" size={11} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runningServices.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium text-emerald-500/70 dark:text-emerald-400/70 uppercase tracking-wider mb-1.5">Running</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{runningServices.map(s => {
|
||||||
|
const uuid = isDiscoveredService(s) ? s.uuid : null;
|
||||||
|
const loading = uuid ? controlling[uuid] : false;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={s.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-100 dark:border-emerald-800/30"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 flex-shrink-0" />
|
||||||
|
<a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:underline">{s.name}</a>
|
||||||
|
<span className="flex items-center gap-0.5 ml-0.5">
|
||||||
|
{isDiscoveredService(s) && (
|
||||||
|
<a
|
||||||
|
href={getCoolifyUrl(s)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Manage ${s.name} in Coolify`}
|
||||||
|
className="p-0.5 rounded hover:bg-emerald-100 dark:hover:bg-emerald-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={11} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{uuid && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => controlService(s, 'restart')}
|
||||||
|
disabled={loading}
|
||||||
|
title={`Restart ${s.name}`}
|
||||||
|
className="p-0.5 rounded hover:bg-emerald-100 dark:hover:bg-emerald-800/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Icon name="loader" size={11} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon name="refresh-cw" size={11} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => controlService(s, 'stop')}
|
||||||
|
disabled={loading}
|
||||||
|
title={`Stop ${s.name}`}
|
||||||
|
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-800/30 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Icon name="power" size={11} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Deployments */}
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
<SectionHeader
|
||||||
|
icon="rocket"
|
||||||
|
title="Recent Deployments"
|
||||||
|
tabTarget="deployments"
|
||||||
|
onNavigate={setActiveTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deploymentsLoading && deployments.length === 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
<div className="h-4 flex-1 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
<div className="h-3 w-12 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentDeployments.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentDeployments.map(d => (
|
||||||
|
<div key={d.deployment_uuid} className="flex items-center gap-3 text-sm">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLORS[d.status]}`} />
|
||||||
|
<span className="text-slate-900 dark:text-stone-100 truncate flex-1 font-medium">
|
||||||
|
{d.application_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-600 flex-shrink-0">
|
||||||
|
{STATUS_LABELS[d.status as DeploymentStatus] || d.status}
|
||||||
|
</span>
|
||||||
|
{d.duration != null && d.duration > 0 && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
|
||||||
|
{formatDuration(d.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-600 tabular-nums flex-shrink-0">
|
||||||
|
{formatRelativeTime(d.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400 dark:text-stone-600">No deployments yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Projects */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="box" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{projects.map(project => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.name}
|
||||||
|
project={project}
|
||||||
|
services={services}
|
||||||
|
healthStatus={healthStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Quick Links */}
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="external-link" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
Quick Links
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
|
{quickLinks.map(link => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name={link.icon} size={16} className="text-slate-500 dark:text-stone-500 flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-stone-100 truncate">{link.name}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 dark:text-stone-600 truncate">{link.desc}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
nuc-portal/src/components/SearchBar.tsx
Normal file
60
nuc-portal/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const { searchQuery, setSearchQuery } = usePortal();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Keyboard shortcut (Cmd+K or Ctrl+K)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
// Clear search on Escape
|
||||||
|
if (e.key === 'Escape' && searchQuery) {
|
||||||
|
setSearchQuery('');
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [searchQuery, setSearchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Icon
|
||||||
|
name="search"
|
||||||
|
size={18}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-stone-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search services and bookmarks..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-16 py-2.5 bg-white dark:bg-stone-900 border border-slate-200 dark:border-stone-800 rounded-lg text-sm text-slate-900 dark:text-stone-100 placeholder-slate-400 dark:placeholder-stone-500 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-stone-600 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{searchQuery ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="p-1 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={14} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-xs text-slate-400 dark:text-stone-600 bg-slate-100 dark:bg-stone-800 rounded border border-slate-200 dark:border-stone-700">
|
||||||
|
<span className="text-xs">⌘</span>K
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
nuc-portal/src/components/ServiceCard.tsx
Normal file
239
nuc-portal/src/components/ServiceCard.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Service, DiscoveredService, getCoolifyUrl, getDozzleUrl } from '@/lib/services';
|
||||||
|
import { HealthStatus } from '@/lib/PortalContext';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
|
interface ServiceCardProps {
|
||||||
|
service: Service;
|
||||||
|
status: HealthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColors: Record<HealthStatus, string> = {
|
||||||
|
running: 'border-l-emerald-500',
|
||||||
|
stopped: 'border-l-red-500',
|
||||||
|
unknown: 'border-l-slate-400 dark:border-l-stone-600',
|
||||||
|
loading: 'border-l-amber-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPillStyles: Record<HealthStatus, string> = {
|
||||||
|
running: 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400',
|
||||||
|
stopped: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||||
|
unknown: 'bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-500',
|
||||||
|
loading: 'bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<HealthStatus, string> = {
|
||||||
|
running: 'Running',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
loading: 'Checking...',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons: Record<HealthStatus, string> = {
|
||||||
|
running: 'circle',
|
||||||
|
stopped: 'power',
|
||||||
|
unknown: 'circle',
|
||||||
|
loading: 'loader',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDiscovered(service: Service): service is DiscoveredService {
|
||||||
|
return 'source' in service && (service as DiscoveredService).source === 'discovered';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFqdnLabel(service: Service): string | null {
|
||||||
|
if (!isDiscovered(service) || !service.fqdn) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(service.fqdn);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
if (hostname.endsWith('.nuc.lan')) {
|
||||||
|
return hostname.replace('.nuc.lan', '');
|
||||||
|
}
|
||||||
|
return hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceBadge(service: Service): string | null {
|
||||||
|
if (!isDiscovered(service)) return null;
|
||||||
|
if (service.resourceType === 'database') return 'DB';
|
||||||
|
if (service.resourceType === 'application') return 'App';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceCard({ service, status }: ServiceCardProps) {
|
||||||
|
const fqdnLabel = getFqdnLabel(service);
|
||||||
|
const resourceBadge = getResourceBadge(service);
|
||||||
|
const discovered = isDiscovered(service);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
|
const isStopped = status === 'stopped' || status === 'unknown';
|
||||||
|
|
||||||
|
const controlService = useCallback(async (action: 'start' | 'stop' | 'restart') => {
|
||||||
|
if (!discovered) return;
|
||||||
|
setLoading(true);
|
||||||
|
setConfirmStop(false);
|
||||||
|
try {
|
||||||
|
await fetch('/api/control', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
uuid: service.uuid,
|
||||||
|
resourceType: service.resourceType,
|
||||||
|
action,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setTimeout(() => setLoading(false), 3000);
|
||||||
|
}
|
||||||
|
}, [discovered, service]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
if (confirmStop) {
|
||||||
|
controlService('stop');
|
||||||
|
} else {
|
||||||
|
setConfirmStop(true);
|
||||||
|
setTimeout(() => setConfirmStop(false), 3000);
|
||||||
|
}
|
||||||
|
}, [confirmStop, controlService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`group relative p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 border-l-[3px] ${borderColors[status]} shadow-sm hover:shadow-md transition-all duration-200 ${isStopped ? 'opacity-75 hover:opacity-100' : ''}`}>
|
||||||
|
{/* Top row: badge */}
|
||||||
|
<div className="absolute top-3 right-3 flex items-center gap-1.5">
|
||||||
|
{resourceBadge && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-400">
|
||||||
|
{resourceBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service info (non-clickable) */}
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3">
|
||||||
|
<Icon
|
||||||
|
name={service.icon}
|
||||||
|
size={20}
|
||||||
|
className="text-slate-600 dark:text-stone-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
|
||||||
|
{service.name}
|
||||||
|
</h3>
|
||||||
|
{service.description && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-stone-500 line-clamp-2">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{fqdnLabel && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-mono">
|
||||||
|
{fqdnLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{service.port > 0 && (
|
||||||
|
<span className="text-xs text-slate-400 dark:text-stone-600 font-mono">
|
||||||
|
:{service.port}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: status + links + controls */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between">
|
||||||
|
{/* Left: status pill */}
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium ${statusPillStyles[status]}`}>
|
||||||
|
<Icon
|
||||||
|
name={loading ? 'loader' : statusIcons[status]}
|
||||||
|
size={10}
|
||||||
|
className={loading || status === 'loading' ? 'animate-spin' : ''}
|
||||||
|
/>
|
||||||
|
{loading ? 'Processing...' : statusLabels[status]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Right: links + action buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Open website */}
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Open website"
|
||||||
|
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:bg-slate-100 dark:hover:bg-stone-800 hover:text-slate-600 dark:hover:text-stone-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="external-link" size={14} />
|
||||||
|
</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 */}
|
||||||
|
{discovered && (
|
||||||
|
<a
|
||||||
|
href={getCoolifyUrl(service as DiscoveredService)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Manage in Coolify"
|
||||||
|
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider between links and controls */}
|
||||||
|
{discovered && !loading && (status === 'running' || isStopped) && (
|
||||||
|
<span className="w-px h-4 bg-slate-200 dark:bg-stone-700 mx-0.5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Control buttons */}
|
||||||
|
{discovered && !loading && (
|
||||||
|
<>
|
||||||
|
{isStopped ? (
|
||||||
|
<button
|
||||||
|
onClick={() => controlService('start')}
|
||||||
|
title="Start"
|
||||||
|
className="p-1.5 rounded-md bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="play" size={14} />
|
||||||
|
</button>
|
||||||
|
) : status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => controlService('restart')}
|
||||||
|
title="Restart"
|
||||||
|
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:bg-slate-100 dark:hover:bg-stone-800 hover:text-slate-600 dark:hover:text-stone-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="refresh-cw" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
title={confirmStop ? 'Click again to confirm' : 'Stop'}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
|
confirmStop
|
||||||
|
? 'bg-red-100 dark:bg-red-900/40 text-red-500 dark:text-red-400 animate-pulse'
|
||||||
|
: 'text-slate-400 dark:text-stone-500 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 dark:hover:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="power" size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
nuc-portal/src/components/SystemTrends.tsx
Normal file
185
nuc-portal/src/components/SystemTrends.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
|
import { Icon } from './Icons';
|
||||||
|
import type { MetricSeries } from '@/lib/stats';
|
||||||
|
import { formatBytes } from '@/lib/stats';
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SparkChartProps {
|
||||||
|
label: string;
|
||||||
|
series: MetricSeries;
|
||||||
|
color: string;
|
||||||
|
fillColor: string;
|
||||||
|
formatValue: (v: number) => string;
|
||||||
|
domain?: [number, number];
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparkChart({ label, series, color, fillColor, formatValue, domain, unit }: SparkChartProps) {
|
||||||
|
const chartData = series.map(([ts, val]) => ({ ts, value: val }));
|
||||||
|
const lastVal = series.length > 0 ? series[series.length - 1][1] : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-stone-500">{label}</span>
|
||||||
|
<span className="text-sm font-semibold tabular-nums" style={{ color }}>
|
||||||
|
{formatValue(lastVal)}{unit || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-20">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`grad-${label}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={fillColor} stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor={fillColor} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="ts"
|
||||||
|
type="number"
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
tickFormatter={formatTime}
|
||||||
|
tick={{ fontSize: 9, fill: '#78716c' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickCount={4}
|
||||||
|
/>
|
||||||
|
{domain && (
|
||||||
|
<YAxis hide domain={domain} />
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'rgba(28, 25, 23, 0.95)',
|
||||||
|
border: '1px solid rgba(120, 113, 108, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '6px 10px',
|
||||||
|
}}
|
||||||
|
labelFormatter={(label) => formatTime(Number(label))}
|
||||||
|
formatter={(value) => [formatValue(Number(value)) + (unit || ''), label]}
|
||||||
|
cursor={{ stroke: 'rgba(120, 113, 108, 0.3)' }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={`url(#grad-${label})`}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShimmerChart() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<div className="h-3 w-10 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
<div className="h-3 w-12 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-20 rounded bg-slate-100 dark:bg-stone-800/50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemTrendsProps {
|
||||||
|
uptimeLabel?: string;
|
||||||
|
loadAvg?: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
||||||
|
const { metrics } = usePortal();
|
||||||
|
|
||||||
|
const loading = !metrics;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
||||||
|
{/* Header with title, live stats, and Grafana link */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-stone-100 flex items-center gap-2">
|
||||||
|
<Icon name="activity" size={16} className="text-slate-400 dark:text-stone-500" />
|
||||||
|
System Trends
|
||||||
|
<span className="text-[10px] font-normal text-slate-400 dark:text-stone-600">6h</span>
|
||||||
|
</h2>
|
||||||
|
{(uptimeLabel || loadAvg) && (
|
||||||
|
<div className="hidden sm:flex items-center gap-3 text-[11px] text-slate-400 dark:text-stone-600">
|
||||||
|
<span className="w-px h-3 bg-slate-200 dark:bg-stone-700" />
|
||||||
|
{uptimeLabel && <span>Up {uptimeLabel}</span>}
|
||||||
|
{loadAvg && <span>Load {loadAvg.map(v => v.toFixed(1)).join(' ')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={clientConfig.grafanaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-slate-400 dark:text-stone-600 hover:text-slate-600 dark:hover:text-stone-400 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Grafana
|
||||||
|
<Icon name="external-link" size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
<ShimmerChart />
|
||||||
|
<ShimmerChart />
|
||||||
|
<ShimmerChart />
|
||||||
|
<ShimmerChart />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
<SparkChart
|
||||||
|
label="CPU"
|
||||||
|
series={metrics.cpu}
|
||||||
|
color="#10b981"
|
||||||
|
fillColor="#10b981"
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<SparkChart
|
||||||
|
label="RAM"
|
||||||
|
series={metrics.ram}
|
||||||
|
color="#f59e0b"
|
||||||
|
fillColor="#f59e0b"
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
<SparkChart
|
||||||
|
label="Temp"
|
||||||
|
series={metrics.temp}
|
||||||
|
color="#ef4444"
|
||||||
|
fillColor="#ef4444"
|
||||||
|
formatValue={(v) => `${v.toFixed(0)}`}
|
||||||
|
unit="°C"
|
||||||
|
/>
|
||||||
|
<SparkChart
|
||||||
|
label="Network"
|
||||||
|
series={metrics.netRx.map(([ts, val], i) => {
|
||||||
|
const tx = metrics.netTx[i]?.[1] || 0;
|
||||||
|
return [ts, val + tx] as [number, number];
|
||||||
|
})}
|
||||||
|
color="#6366f1"
|
||||||
|
fillColor="#6366f1"
|
||||||
|
formatValue={(v) => formatBytes(v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
nuc-portal/src/components/VitalsBar.tsx
Normal file
59
nuc-portal/src/components/VitalsBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { getVitalsBg, getVitalsTrack } from '@/lib/stats';
|
||||||
|
|
||||||
|
function MiniBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
|
||||||
|
const bg = getVitalsBg(percent);
|
||||||
|
const track = getVitalsTrack(percent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[11px] font-medium text-slate-500 dark:text-stone-500 w-8 text-right">{label}</span>
|
||||||
|
<div className={`w-16 h-1.5 rounded-full ${track}`}>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${bg} transition-all duration-500`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] tabular-nums text-slate-600 dark:text-stone-400 w-8">{Math.round(percent)}%</span>
|
||||||
|
{detail && (
|
||||||
|
<span className="text-[10px] text-slate-400 dark:text-stone-600 hidden lg:inline">{detail}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VitalsBar() {
|
||||||
|
const { systemStats, statsLoading, statsError } = usePortal();
|
||||||
|
|
||||||
|
if (statsError && !systemStats) return null;
|
||||||
|
|
||||||
|
if (statsLoading && !systemStats) {
|
||||||
|
return (
|
||||||
|
<div className="hidden sm:flex items-center gap-4 px-4 sm:px-6 py-1 border-t border-slate-100 dark:border-stone-800/50">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-8 h-2 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
<div className="w-16 h-1.5 rounded-full bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
<div className="w-8 h-2 rounded bg-slate-200 dark:bg-stone-800 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemStats) return null;
|
||||||
|
|
||||||
|
const ramDetail = `${(systemStats.ram_used_mb / 1024).toFixed(1)}/${(systemStats.ram_total_mb / 1024).toFixed(1)}G`;
|
||||||
|
const showSwap = systemStats.swap_percent > 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden sm:flex items-center gap-4 px-4 sm:px-6 py-1 border-t border-slate-100 dark:border-stone-800/50">
|
||||||
|
<MiniBar label="CPU" percent={systemStats.cpu_percent} />
|
||||||
|
<MiniBar label="RAM" percent={systemStats.ram_percent} detail={ramDetail} />
|
||||||
|
<MiniBar label="Disk" percent={systemStats.disk_percent} />
|
||||||
|
{showSwap && <MiniBar label="Swap" percent={systemStats.swap_percent} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
nuc-portal/src/components/index.ts
Normal file
14
nuc-portal/src/components/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { Icon, icons } from './Icons';
|
||||||
|
export { ServiceCard } from './ServiceCard';
|
||||||
|
export { BookmarkCard } from './BookmarkCard';
|
||||||
|
export { CategorySection } from './CategorySection';
|
||||||
|
export { SearchBar } from './SearchBar';
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { Section } from './ui/Section';
|
||||||
|
export { DeploymentsTable } from './DeploymentsTable';
|
||||||
|
export { DeploymentLogs } from './DeploymentLogs';
|
||||||
|
export { DeploymentDashboard } from './DeploymentDashboard';
|
||||||
|
export { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from './DeploymentSkeleton';
|
||||||
|
export { VitalsBar } from './VitalsBar';
|
||||||
|
export { OverviewTab } from './OverviewTab';
|
||||||
|
export { SystemTrends } from './SystemTrends';
|
||||||
19
nuc-portal/src/components/ui/Section.tsx
Normal file
19
nuc-portal/src/components/ui/Section.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, description, children }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-stone-900 rounded-2xl p-8 shadow-sm mb-6 border border-slate-100 dark:border-stone-800">
|
||||||
|
{title && <h2 className="text-xl font-semibold text-slate-900 dark:text-stone-50 mb-1">{title}</h2>}
|
||||||
|
{description && <p className="text-sm text-slate-600 dark:text-stone-400 mb-6">{description}</p>}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
nuc-portal/src/components/ui/index.ts
Normal file
1
nuc-portal/src/components/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Section } from './Section';
|
||||||
215
nuc-portal/src/lib/PortalContext.tsx
Normal file
215
nuc-portal/src/lib/PortalContext.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import { bookmarks, Service, Bookmark, DiscoveredService, fallbackServices } from './services';
|
||||||
|
import type { Deployment } from './deployments';
|
||||||
|
import type { SystemStats, MetricsData } from './stats';
|
||||||
|
import { useEventStream } from './useEventStream';
|
||||||
|
|
||||||
|
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
|
||||||
|
|
||||||
|
interface HealthState {
|
||||||
|
[serviceName: string]: HealthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortalContextType {
|
||||||
|
services: Service[];
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
healthStatus: HealthState;
|
||||||
|
darkMode: boolean;
|
||||||
|
setDarkMode: (dark: boolean) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
filteredServices: Service[];
|
||||||
|
filteredBookmarks: Bookmark[];
|
||||||
|
refreshHealth: () => Promise<void>;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
// Deployments
|
||||||
|
deployments: Deployment[];
|
||||||
|
deploymentsLoading: boolean;
|
||||||
|
refreshDeployments: () => Promise<void>;
|
||||||
|
activeTab: string;
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
|
// Discovery
|
||||||
|
discoveredServices: DiscoveredService[];
|
||||||
|
discoveryLoading: boolean;
|
||||||
|
discoveryError: boolean;
|
||||||
|
refreshDiscover: () => Promise<void>;
|
||||||
|
// System stats
|
||||||
|
systemStats: SystemStats | null;
|
||||||
|
statsLoading: boolean;
|
||||||
|
statsError: boolean;
|
||||||
|
refreshStats: () => Promise<void>;
|
||||||
|
// SSE
|
||||||
|
connected: boolean;
|
||||||
|
metrics: MetricsData | null;
|
||||||
|
// Deploy action
|
||||||
|
triggerDeploy: (uuid: string) => Promise<void>;
|
||||||
|
// Active deploy logs
|
||||||
|
activeDeployLogs: Array<{ uuid: string; logs: string; status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PortalContext = createContext<PortalContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function PortalProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [darkMode, setDarkMode] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
|
// SSE stream
|
||||||
|
const stream = useEventStream();
|
||||||
|
|
||||||
|
// Apply dark mode to document
|
||||||
|
useEffect(() => {
|
||||||
|
if (darkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
// Persist dark mode preference
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('portal-dark-mode');
|
||||||
|
if (saved !== null) {
|
||||||
|
setDarkMode(saved === 'true');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('portal-dark-mode', String(darkMode));
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
// Derive health status from discovered services
|
||||||
|
const healthStatus: HealthState = {};
|
||||||
|
const discoveredServices = stream.services as DiscoveredService[];
|
||||||
|
for (const svc of discoveredServices) {
|
||||||
|
if (svc.coolifyStatus?.startsWith('running')) {
|
||||||
|
healthStatus[svc.name] = 'running';
|
||||||
|
} else if (svc.coolifyStatus?.startsWith('exited') || svc.coolifyStatus === 'stopped') {
|
||||||
|
healthStatus[svc.name] = 'stopped';
|
||||||
|
} else {
|
||||||
|
healthStatus[svc.name] = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? [
|
||||||
|
...discoveredServices,
|
||||||
|
...fallbackServices.filter(fb =>
|
||||||
|
!discoveredServices.some(d => d.name === fb.name || d.port === fb.port)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: fallbackServices;
|
||||||
|
|
||||||
|
// Filter services and bookmarks
|
||||||
|
const filteredServices = activeServices.filter(service => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
service.name.toLowerCase().includes(query) ||
|
||||||
|
service.description?.toLowerCase().includes(query) ||
|
||||||
|
service.category.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredBookmarks = bookmarks.filter(bookmark => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
bookmark.name.toLowerCase().includes(query) ||
|
||||||
|
bookmark.description?.toLowerCase().includes(query) ||
|
||||||
|
bookmark.category.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy refresh callbacks (now no-ops since SSE handles updates)
|
||||||
|
const refreshHealth = useCallback(async () => {}, []);
|
||||||
|
const refreshDiscover = useCallback(async () => {}, []);
|
||||||
|
const refreshStats = useCallback(async () => {}, []);
|
||||||
|
const refreshDeployments = useCallback(async () => {}, []);
|
||||||
|
|
||||||
|
// Deploy trigger
|
||||||
|
const triggerDeploy = useCallback(async (uuid: string) => {
|
||||||
|
await fetch('/api/control', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uuid, action: 'deploy' }),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalContext.Provider
|
||||||
|
value={{
|
||||||
|
services: activeServices,
|
||||||
|
bookmarks,
|
||||||
|
healthStatus,
|
||||||
|
darkMode,
|
||||||
|
setDarkMode,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
filteredServices,
|
||||||
|
filteredBookmarks,
|
||||||
|
refreshHealth,
|
||||||
|
isRefreshing: false,
|
||||||
|
deployments: stream.deployments,
|
||||||
|
deploymentsLoading: !stream.connected && stream.deployments.length === 0,
|
||||||
|
refreshDeployments,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
discoveredServices,
|
||||||
|
discoveryLoading: !stream.connected && discoveredServices.length === 0,
|
||||||
|
discoveryError: !stream.connected && discoveredServices.length === 0,
|
||||||
|
refreshDiscover,
|
||||||
|
systemStats: stream.stats,
|
||||||
|
statsLoading: !stream.connected && !stream.stats,
|
||||||
|
statsError: false,
|
||||||
|
refreshStats,
|
||||||
|
connected: stream.connected,
|
||||||
|
metrics: stream.metrics,
|
||||||
|
triggerDeploy,
|
||||||
|
activeDeployLogs: stream.activeDeployLogs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PortalContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortal() {
|
||||||
|
const context = useContext(PortalContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('usePortal must be used within a PortalProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
66
nuc-portal/src/lib/config.ts
Normal file
66
nuc-portal/src/lib/config.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// 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 = {
|
||||||
|
coolifyToken: process.env.COOLIFY_API_TOKEN || '',
|
||||||
|
coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://localhost:8000/api/v1',
|
||||||
|
coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss',
|
||||||
|
coolifyDbUrl: process.env.COOLIFY_DB_URL || '',
|
||||||
|
prometheusUrl: process.env.PROMETHEUS_URL || 'http://localhost:9091',
|
||||||
|
nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || 'localhost:9100',
|
||||||
|
nicDevice: process.env.NIC_DEVICE || 'eno1',
|
||||||
|
nucHost: process.env.NUC_HOST || 'localhost',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix)
|
||||||
|
// Uses domain names for browser access (works via Tailscale from anywhere)
|
||||||
|
export const clientConfig = {
|
||||||
|
// Primary domain-based URLs (preferred - work from anywhere)
|
||||||
|
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',
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
140
nuc-portal/src/lib/coolify-db.ts
Normal file
140
nuc-portal/src/lib/coolify-db.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { serverConfig } from './config';
|
||||||
|
import type { Deployment, DeploymentStatus } from './deployments';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
let pool: pg.Pool | null = null;
|
||||||
|
|
||||||
|
function parseDbUrl(url: string): pg.PoolConfig {
|
||||||
|
// Manual parsing to handle special chars in password (/, =, etc.)
|
||||||
|
const match = url.match(/^postgres(?:ql)?:\/\/([^:]+):(.+)@([^:]+):(\d+)\/(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
return { user: match[1], password: match[2], host: match[3], port: parseInt(match[4]), database: match[5] };
|
||||||
|
}
|
||||||
|
return { connectionString: url };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPool(): pg.Pool {
|
||||||
|
if (!pool) {
|
||||||
|
pool = new Pool({
|
||||||
|
...parseDbUrl(serverConfig.coolifyDbUrl),
|
||||||
|
max: 3,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Coolify DB pool error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatus(status: string): DeploymentStatus {
|
||||||
|
if (status === 'failed') return 'error';
|
||||||
|
if (status === 'cancelled-by-user') return 'cancelled';
|
||||||
|
return status as DeploymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToDeployment(row: Record<string, unknown>): Deployment {
|
||||||
|
const createdAt = row.created_at as string;
|
||||||
|
const updatedAt = row.updated_at as string;
|
||||||
|
let duration: number | undefined;
|
||||||
|
if (createdAt && updatedAt) {
|
||||||
|
const start = new Date(createdAt).getTime();
|
||||||
|
const end = new Date(updatedAt).getTime();
|
||||||
|
duration = Math.max(0, Math.floor((end - start) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deployment_uuid: row.deployment_uuid as string,
|
||||||
|
application_uuid: (row.application_uuid as string) || 'unknown',
|
||||||
|
application_name: (row.application_name as string) || 'Unknown App',
|
||||||
|
application_fqdn: (row.application_fqdn as string) || undefined,
|
||||||
|
status: mapStatus(row.status as string),
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
git_branch: (row.git_branch as string) || 'main',
|
||||||
|
git_commit_sha: (row.commit as string) || undefined,
|
||||||
|
commit_message: (row.commit_message as string) || undefined,
|
||||||
|
is_webhook: row.is_webhook as boolean | undefined,
|
||||||
|
is_api: row.is_api as boolean | undefined,
|
||||||
|
logs: (row.logs as string) || undefined,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeployments(limit = 50): Promise<Deployment[]> {
|
||||||
|
const db = getPool();
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
q.deployment_uuid,
|
||||||
|
a.uuid AS application_uuid,
|
||||||
|
COALESCE(q.application_name, a.name) AS application_name,
|
||||||
|
a.fqdn AS application_fqdn,
|
||||||
|
q.status,
|
||||||
|
q.created_at,
|
||||||
|
q.updated_at,
|
||||||
|
a.git_branch,
|
||||||
|
q.commit,
|
||||||
|
q.commit_message,
|
||||||
|
q.is_webhook,
|
||||||
|
q.is_api
|
||||||
|
FROM application_deployment_queues q
|
||||||
|
LEFT JOIN applications a ON a.id = q.application_id::bigint
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
`, [limit]);
|
||||||
|
|
||||||
|
const deployments = rows.map(rowToDeployment);
|
||||||
|
|
||||||
|
// Mark latest finished deployment per app as current
|
||||||
|
const latestByApp = new Map<string, string>();
|
||||||
|
for (const d of deployments) {
|
||||||
|
if (d.status === 'finished' && !latestByApp.has(d.application_uuid)) {
|
||||||
|
latestByApp.set(d.application_uuid, d.deployment_uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const d of deployments) {
|
||||||
|
d.is_current = latestByApp.get(d.application_uuid) === d.deployment_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deployments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeploymentDetail(uuid: string): Promise<Deployment | null> {
|
||||||
|
const db = getPool();
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
SELECT
|
||||||
|
q.deployment_uuid,
|
||||||
|
a.uuid AS application_uuid,
|
||||||
|
COALESCE(q.application_name, a.name) AS application_name,
|
||||||
|
a.fqdn AS application_fqdn,
|
||||||
|
q.status,
|
||||||
|
q.created_at,
|
||||||
|
q.updated_at,
|
||||||
|
a.git_branch,
|
||||||
|
q.commit,
|
||||||
|
q.commit_message,
|
||||||
|
q.is_webhook,
|
||||||
|
q.is_api,
|
||||||
|
q.logs
|
||||||
|
FROM application_deployment_queues q
|
||||||
|
LEFT JOIN applications a ON a.id = q.application_id::bigint
|
||||||
|
WHERE q.deployment_uuid = $1
|
||||||
|
`, [uuid]);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return rowToDeployment(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActiveDeploymentLogs(): Promise<Array<{ uuid: string; logs: string; status: string }>> {
|
||||||
|
const db = getPool();
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
SELECT deployment_uuid AS uuid, logs, status
|
||||||
|
FROM application_deployment_queues
|
||||||
|
WHERE status IN ('in_progress', 'queued')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
return rows as Array<{ uuid: string; logs: string; status: string }>;
|
||||||
|
}
|
||||||
97
nuc-portal/src/lib/coolify.ts
Normal file
97
nuc-portal/src/lib/coolify.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { serverConfig } from './config';
|
||||||
|
|
||||||
|
const { coolifyApiUrl, coolifyToken, coolifyServerUuid } = serverConfig;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json() as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoolifyResource {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchResources(): Promise<CoolifyResource[] | null> {
|
||||||
|
return fetchJson<CoolifyResource[]>(
|
||||||
|
`${coolifyApiUrl}/servers/${coolifyServerUuid}/resources`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppDetail(uuid: string) {
|
||||||
|
return fetchJson<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
fqdn: string | null;
|
||||||
|
ports_exposes: string | null;
|
||||||
|
ports_mappings: string | null;
|
||||||
|
status: string;
|
||||||
|
description: string | null;
|
||||||
|
}>(`${coolifyApiUrl}/applications/${uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceDetail(uuid: string) {
|
||||||
|
return fetchJson<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
applications?: Array<{
|
||||||
|
name: string;
|
||||||
|
human_name: string | null;
|
||||||
|
fqdn: string | null;
|
||||||
|
ports: string | null;
|
||||||
|
status: string;
|
||||||
|
image: string | null;
|
||||||
|
}>;
|
||||||
|
databases?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}>(`${coolifyApiUrl}/services/${uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function controlResource(
|
||||||
|
uuid: string,
|
||||||
|
resourceType: 'application' | 'service' | 'database',
|
||||||
|
action: 'start' | 'stop' | 'restart'
|
||||||
|
): Promise<{ ok: boolean; status: number }> {
|
||||||
|
const endpoint = `${coolifyApiUrl}/${resourceType}s/${uuid}/${action}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
return { ok: res.ok, status: res.status };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerDeploy(uuid: string): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${coolifyApiUrl}/deploy?uuid=${uuid}&force=false`, {
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
return { ok: false, message: `${res.status}: ${text}` };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
103
nuc-portal/src/lib/deployments.ts
Normal file
103
nuc-portal/src/lib/deployments.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export type DeploymentStatus = 'queued' | 'in_progress' | 'finished' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
deployment_uuid: string;
|
||||||
|
application_uuid: string;
|
||||||
|
application_name: string;
|
||||||
|
application_fqdn?: string; // URL to deployed site
|
||||||
|
status: DeploymentStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
server_name?: string;
|
||||||
|
git_branch?: string;
|
||||||
|
git_commit_sha?: string;
|
||||||
|
commit_message?: string;
|
||||||
|
is_webhook?: boolean;
|
||||||
|
is_api?: boolean;
|
||||||
|
logs?: string;
|
||||||
|
// Computed fields
|
||||||
|
duration?: number; // in seconds
|
||||||
|
is_current?: boolean; // latest deployment for this app
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeploymentLog {
|
||||||
|
timestamp: string;
|
||||||
|
output: string;
|
||||||
|
type: 'stdout' | 'stderr';
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<DeploymentStatus, string> = {
|
||||||
|
finished: 'bg-cyan-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
in_progress: 'bg-orange-500',
|
||||||
|
queued: 'bg-gray-400',
|
||||||
|
cancelled: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<DeploymentStatus, string> = {
|
||||||
|
finished: 'Ready',
|
||||||
|
error: 'Error',
|
||||||
|
in_progress: 'Building',
|
||||||
|
queued: 'Queued',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateCommitMessage(message: string, maxLength = 40): string {
|
||||||
|
if (!message) return '';
|
||||||
|
const firstLine = message.split('\n')[0];
|
||||||
|
if (firstLine.length <= maxLength) return firstLine;
|
||||||
|
return firstLine.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeploymentLogs(logsJson: string | undefined): DeploymentLog[] {
|
||||||
|
if (!logsJson) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(logsJson);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((log: DeploymentLog) => !log.hidden);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, treat as plain text
|
||||||
|
return logsJson.split('\n').filter(Boolean).map((line) => ({
|
||||||
|
timestamp: '',
|
||||||
|
output: line,
|
||||||
|
type: 'stdout' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
264
nuc-portal/src/lib/docker.ts
Normal file
264
nuc-portal/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;
|
||||||
|
}
|
||||||
241
nuc-portal/src/lib/event-manager.ts
Normal file
241
nuc-portal/src/lib/event-manager.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { fetchInstantStats } from './prometheus';
|
||||||
|
import { fetchRangeMetrics } from './prometheus';
|
||||||
|
import { fetchDeployments, fetchActiveDeploymentLogs } from './coolify-db';
|
||||||
|
import type { DiscoveredService, ServiceCategory } from './services';
|
||||||
|
import { lookupService, lookupDatabase } from './service-registry';
|
||||||
|
import { fetchResources, fetchAppDetail, fetchServiceDetail } from './coolify';
|
||||||
|
import { serverConfig } from './config';
|
||||||
|
|
||||||
|
type SSEClient = {
|
||||||
|
write: (data: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hash(data: unknown): string {
|
||||||
|
return createHash('md5').update(JSON.stringify(data)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventManager {
|
||||||
|
private clients = new Set<SSEClient>();
|
||||||
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
private cache: Record<string, { hash: string; data: unknown }> = {};
|
||||||
|
|
||||||
|
addClient(client: SSEClient) {
|
||||||
|
this.clients.add(client);
|
||||||
|
// Send cached data immediately
|
||||||
|
for (const [event, { data }] of Object.entries(this.cache)) {
|
||||||
|
this.sendTo(client, event, data);
|
||||||
|
}
|
||||||
|
if (this.clients.size === 1) {
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeClient(client: SSEClient) {
|
||||||
|
this.clients.delete(client);
|
||||||
|
if (this.clients.size === 0) {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientCount() {
|
||||||
|
return this.clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendTo(client: SSEClient, event: string, data: unknown) {
|
||||||
|
try {
|
||||||
|
client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
} catch {
|
||||||
|
this.clients.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(event: string, data: unknown) {
|
||||||
|
const newHash = hash(data);
|
||||||
|
const cached = this.cache[event];
|
||||||
|
|
||||||
|
// Only broadcast if data changed (except heartbeat and active logs which always send)
|
||||||
|
if (event !== 'heartbeat' && event !== 'deployment:logs' && cached?.hash === newHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache[event] = { hash: newHash, data };
|
||||||
|
for (const client of this.clients) {
|
||||||
|
this.sendTo(client, event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollStats() {
|
||||||
|
try {
|
||||||
|
const stats = await fetchInstantStats();
|
||||||
|
this.broadcast('stats', stats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] stats error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollDiscovery() {
|
||||||
|
try {
|
||||||
|
const discovered = await this.discoverServices();
|
||||||
|
this.broadcast('services', discovered);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] discovery error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollDeployments() {
|
||||||
|
try {
|
||||||
|
const deployments = await fetchDeployments();
|
||||||
|
this.broadcast('deployments', deployments);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] deployments error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollMetrics() {
|
||||||
|
try {
|
||||||
|
const metrics = await fetchRangeMetrics();
|
||||||
|
this.broadcast('metrics', metrics);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] metrics error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollActiveLogs() {
|
||||||
|
try {
|
||||||
|
const logs = await fetchActiveDeploymentLogs();
|
||||||
|
if (logs.length > 0) {
|
||||||
|
this.broadcast('deployment:logs', logs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] active logs error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling() {
|
||||||
|
// Immediate first poll of all sources
|
||||||
|
this.pollStats();
|
||||||
|
this.pollDiscovery();
|
||||||
|
this.pollDeployments();
|
||||||
|
this.pollMetrics();
|
||||||
|
this.pollActiveLogs();
|
||||||
|
|
||||||
|
this.timers = [
|
||||||
|
setInterval(() => this.pollStats(), 15000),
|
||||||
|
setInterval(() => this.pollDiscovery(), 30000),
|
||||||
|
setInterval(() => this.pollDeployments(), 10000),
|
||||||
|
setInterval(() => this.pollMetrics(), 60000),
|
||||||
|
setInterval(() => this.pollActiveLogs(), 2000),
|
||||||
|
setInterval(() => this.broadcast('heartbeat', { ts: Date.now() }), 15000),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling() {
|
||||||
|
for (const timer of this.timers) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
this.timers = [];
|
||||||
|
this.cache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline service discovery (same logic as discover route)
|
||||||
|
private async discoverServices(): Promise<DiscoveredService[]> {
|
||||||
|
const nucHost = serverConfig.nucHost;
|
||||||
|
const resources = await fetchResources();
|
||||||
|
if (!resources) return [];
|
||||||
|
|
||||||
|
const detailPromises = resources.map(async (resource): Promise<DiscoveredService | null> => {
|
||||||
|
if (resource.type === 'application') {
|
||||||
|
const detail = await fetchAppDetail(resource.uuid);
|
||||||
|
if (!detail) return null;
|
||||||
|
const port = this.extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings);
|
||||||
|
const meta = lookupService(detail.name);
|
||||||
|
const url = this.buildUrl(detail.fqdn, port, nucHost);
|
||||||
|
return {
|
||||||
|
name: detail.name, url, port,
|
||||||
|
icon: meta.icon, category: meta.category as ServiceCategory,
|
||||||
|
description: detail.description || meta.description,
|
||||||
|
source: 'discovered', fqdn: detail.fqdn || undefined,
|
||||||
|
resourceType: 'application', uuid: resource.uuid, coolifyStatus: resource.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.type === 'service') {
|
||||||
|
const detail = await fetchServiceDetail(resource.uuid);
|
||||||
|
if (!detail) return null;
|
||||||
|
const app = detail.applications?.[0];
|
||||||
|
const cleanName = resource.name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, '');
|
||||||
|
const meta = lookupService(cleanName);
|
||||||
|
let port = 0;
|
||||||
|
let fqdn: string | undefined;
|
||||||
|
if (app) {
|
||||||
|
port = this.extractPortFromServicePorts(app.ports) || this.extractPort(app.fqdn, null, null);
|
||||||
|
fqdn = app.fqdn && !app.fqdn.includes('sslip.io') ? app.fqdn : undefined;
|
||||||
|
}
|
||||||
|
const url = this.buildUrl(fqdn || null, port, nucHost);
|
||||||
|
return {
|
||||||
|
name: cleanName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||||
|
url, port, icon: meta.icon, category: meta.category as ServiceCategory,
|
||||||
|
description: meta.description, source: 'discovered', fqdn,
|
||||||
|
resourceType: 'service', uuid: resource.uuid, coolifyStatus: resource.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.type.startsWith('standalone-')) {
|
||||||
|
const meta = lookupDatabase(resource.type, resource.name);
|
||||||
|
return {
|
||||||
|
name: resource.name, url: `http://${nucHost}:8000`, port: 0,
|
||||||
|
icon: meta.icon, category: meta.category as ServiceCategory,
|
||||||
|
description: meta.description, source: 'discovered',
|
||||||
|
resourceType: 'database', uuid: resource.uuid, coolifyStatus: resource.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(detailPromises);
|
||||||
|
const discovered: DiscoveredService[] = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled' && r.value) discovered.push(r.value);
|
||||||
|
}
|
||||||
|
discovered.sort((a, b) => {
|
||||||
|
const aR = a.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
|
const bR = b.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
|
if (aR !== bR) return aR - bR;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPort(fqdn: string | null, portsExposes: string | null, portsMappings: string | null): number {
|
||||||
|
if (fqdn) {
|
||||||
|
try { const url = new URL(fqdn); if (url.port) return parseInt(url.port, 10); } catch { /* */ }
|
||||||
|
}
|
||||||
|
if (portsMappings) { const h = portsMappings.split(',')[0].trim().split(':')[0]; if (h) return parseInt(h, 10); }
|
||||||
|
if (portsExposes) return parseInt(portsExposes.split(',')[0].trim(), 10);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPortFromServicePorts(ports: string | null): number {
|
||||||
|
if (!ports) return 0;
|
||||||
|
return parseInt(ports.split(',')[0].trim().split(':')[0], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUrl(fqdn: string | null, port: number, nucHost: string): string {
|
||||||
|
if (fqdn) {
|
||||||
|
try {
|
||||||
|
const url = new URL(fqdn);
|
||||||
|
if (url.hostname.includes('sslip.io')) return `http://${nucHost}:${port || url.port || 80}`;
|
||||||
|
return fqdn;
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
if (port > 0) return `http://${nucHost}:${port}`;
|
||||||
|
return `http://${nucHost}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global singleton (survives hot-reload in dev)
|
||||||
|
const globalForEventManager = globalThis as unknown as { eventManager?: EventManager };
|
||||||
|
export const eventManager = globalForEventManager.eventManager ?? new EventManager();
|
||||||
|
globalForEventManager.eventManager = eventManager;
|
||||||
136
nuc-portal/src/lib/prometheus.ts
Normal file
136
nuc-portal/src/lib/prometheus.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { serverConfig } from './config';
|
||||||
|
import type { SystemStats, MetricsData } from './stats';
|
||||||
|
|
||||||
|
const { prometheusUrl, nodeExporterInstance, nicDevice } = serverConfig;
|
||||||
|
|
||||||
|
interface PrometheusInstantResult {
|
||||||
|
data: {
|
||||||
|
result: Array<{
|
||||||
|
value: [number, string];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrometheusRangeResult {
|
||||||
|
data: {
|
||||||
|
result: Array<{
|
||||||
|
values: Array<[number, string]>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryInstant(query: string): Promise<number> {
|
||||||
|
const params = new URLSearchParams({ query });
|
||||||
|
const res = await fetch(`${prometheusUrl}/api/v1/query?${params}`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const data: PrometheusInstantResult = await res.json();
|
||||||
|
const val = data.data?.result?.[0]?.value?.[1];
|
||||||
|
return val ? parseFloat(val) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRange(query: string, start: number, end: number, step: number): Promise<Array<[number, number]>> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query,
|
||||||
|
start: String(start),
|
||||||
|
end: String(end),
|
||||||
|
step: String(step),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${prometheusUrl}/api/v1/query_range?${params}`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data: PrometheusRangeResult = await res.json();
|
||||||
|
const result = data.data?.result?.[0];
|
||||||
|
if (!result) return [];
|
||||||
|
return result.values.map(([ts, val]) => [ts, parseFloat(val)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const I = nodeExporterInstance;
|
||||||
|
const NIC = nicDevice;
|
||||||
|
|
||||||
|
export async function fetchInstantStats(): Promise<SystemStats> {
|
||||||
|
const [
|
||||||
|
cpuPercent,
|
||||||
|
ramTotalBytes,
|
||||||
|
ramAvailBytes,
|
||||||
|
swapTotalBytes,
|
||||||
|
swapFreeBytes,
|
||||||
|
diskTotalBytes,
|
||||||
|
diskAvailBytes,
|
||||||
|
uptimeSeconds,
|
||||||
|
load1,
|
||||||
|
load5,
|
||||||
|
load15,
|
||||||
|
] = await Promise.all([
|
||||||
|
queryInstant(`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${I}"}[2m])) * 100)`),
|
||||||
|
queryInstant(`node_memory_MemTotal_bytes{instance="${I}"}`),
|
||||||
|
queryInstant(`node_memory_MemAvailable_bytes{instance="${I}"}`),
|
||||||
|
queryInstant(`node_memory_SwapTotal_bytes{instance="${I}"}`),
|
||||||
|
queryInstant(`node_memory_SwapFree_bytes{instance="${I}"}`),
|
||||||
|
queryInstant(`node_filesystem_size_bytes{instance="${I}",mountpoint="/",fstype!="tmpfs"}`),
|
||||||
|
queryInstant(`node_filesystem_avail_bytes{instance="${I}",mountpoint="/",fstype!="tmpfs"}`),
|
||||||
|
queryInstant(`node_time_seconds{instance="${I}"} - node_boot_time_seconds{instance="${I}"}`),
|
||||||
|
queryInstant(`node_load1{instance="${I}"}`),
|
||||||
|
queryInstant(`node_load5{instance="${I}"}`),
|
||||||
|
queryInstant(`node_load15{instance="${I}"}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ramTotalMb = ramTotalBytes / (1024 * 1024);
|
||||||
|
const ramUsedMb = (ramTotalBytes - ramAvailBytes) / (1024 * 1024);
|
||||||
|
const swapTotalMb = swapTotalBytes / (1024 * 1024);
|
||||||
|
const swapUsedMb = (swapTotalBytes - swapFreeBytes) / (1024 * 1024);
|
||||||
|
const diskTotalGb = diskTotalBytes / (1024 * 1024 * 1024);
|
||||||
|
const diskUsedGb = (diskTotalBytes - diskAvailBytes) / (1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu_percent: Math.round(cpuPercent * 10) / 10,
|
||||||
|
ram_total_mb: Math.round(ramTotalMb),
|
||||||
|
ram_used_mb: Math.round(ramUsedMb),
|
||||||
|
ram_percent: ramTotalMb > 0 ? Math.round((ramUsedMb / ramTotalMb) * 1000) / 10 : 0,
|
||||||
|
swap_total_mb: Math.round(swapTotalMb),
|
||||||
|
swap_used_mb: Math.round(swapUsedMb),
|
||||||
|
swap_percent: swapTotalMb > 0 ? Math.round((swapUsedMb / swapTotalMb) * 1000) / 10 : 0,
|
||||||
|
disk_total_gb: Math.round(diskTotalGb * 10) / 10,
|
||||||
|
disk_used_gb: Math.round(diskUsedGb * 10) / 10,
|
||||||
|
disk_percent: diskTotalGb > 0 ? Math.round((diskUsedGb / diskTotalGb) * 1000) / 10 : 0,
|
||||||
|
uptime_seconds: Math.round(uptimeSeconds),
|
||||||
|
load_avg: [
|
||||||
|
Math.round(load1 * 100) / 100,
|
||||||
|
Math.round(load5 * 100) / 100,
|
||||||
|
Math.round(load15 * 100) / 100,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRangeMetrics(hours = 6): Promise<MetricsData> {
|
||||||
|
const end = Math.floor(Date.now() / 1000);
|
||||||
|
const start = end - hours * 3600;
|
||||||
|
const step = 120;
|
||||||
|
|
||||||
|
const [cpu, ram, netRx, netTx, temp] = await Promise.all([
|
||||||
|
queryRange(
|
||||||
|
`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${I}"}[2m])) * 100)`,
|
||||||
|
start, end, step
|
||||||
|
),
|
||||||
|
queryRange(
|
||||||
|
`(1 - node_memory_MemAvailable_bytes{instance="${I}"} / node_memory_MemTotal_bytes{instance="${I}"}) * 100`,
|
||||||
|
start, end, step
|
||||||
|
),
|
||||||
|
queryRange(
|
||||||
|
`rate(node_network_receive_bytes_total{instance="${I}",device="${NIC}"}[2m])`,
|
||||||
|
start, end, step
|
||||||
|
),
|
||||||
|
queryRange(
|
||||||
|
`rate(node_network_transmit_bytes_total{instance="${I}",device="${NIC}"}[2m])`,
|
||||||
|
start, end, step
|
||||||
|
),
|
||||||
|
queryRange(
|
||||||
|
`max(node_hwmon_temp_celsius{instance="${I}",chip="platform_coretemp_0"})`,
|
||||||
|
start, end, step
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { cpu, ram, netRx, netTx, temp };
|
||||||
|
}
|
||||||
170
nuc-portal/src/lib/s3.ts
Normal file
170
nuc-portal/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 };
|
||||||
122
nuc-portal/src/lib/service-registry.ts
Normal file
122
nuc-portal/src/lib/service-registry.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ServiceCategory } from './services';
|
||||||
|
|
||||||
|
export interface ServiceMeta {
|
||||||
|
icon: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map known service names/types to icons, categories, and descriptions.
|
||||||
|
// Keys are matched case-insensitively against the resource name from Coolify.
|
||||||
|
const registry: Record<string, ServiceMeta> = {
|
||||||
|
// Infrastructure
|
||||||
|
'coolify': { icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
|
||||||
|
'dozzle': { icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' },
|
||||||
|
'traefik': { icon: 'globe', category: 'infrastructure', description: 'Reverse proxy & load balancer' },
|
||||||
|
'tailscale': { icon: 'globe', category: 'infrastructure', description: 'Mesh VPN & secure access' },
|
||||||
|
'cloudflared': { icon: 'globe', category: 'infrastructure', description: 'Cloudflare tunnel' },
|
||||||
|
'crowdsec': { icon: 'shield', category: 'infrastructure', description: 'Collaborative security engine' },
|
||||||
|
'homepage': { icon: 'layout', category: 'infrastructure', description: 'Dashboard & start page' },
|
||||||
|
'nuc portal': { icon: 'monitor', category: 'infrastructure', description: 'NUC server dashboard' },
|
||||||
|
'playwriter': { icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' },
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
'n8n': { icon: 'workflow', category: 'automation', description: 'Workflow automation platform' },
|
||||||
|
|
||||||
|
// Development
|
||||||
|
'gitea': { icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' },
|
||||||
|
'cloudbeaver': { icon: 'database', category: 'development', description: 'Database management UI' },
|
||||||
|
'adminer': { icon: 'table', category: 'development', description: 'Lightweight database admin' },
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
'outline': { icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' },
|
||||||
|
'getoutline': { icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' },
|
||||||
|
'nocodb': { icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' },
|
||||||
|
'knosia': { icon: 'book', category: 'knowledge', description: 'Knowledge base' },
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
'filebrowser': { icon: 'folder', category: 'storage', description: 'Web file manager' },
|
||||||
|
'minio': { icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' },
|
||||||
|
'kopia': { icon: 'archive', category: 'storage', description: 'Backup & restore' },
|
||||||
|
'palmr': { icon: 'folder', category: 'storage', description: 'File sharing platform' },
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
'uptime kuma': { icon: 'activity', category: 'monitoring', description: 'Service status monitoring' },
|
||||||
|
'uptime-kuma': { icon: 'activity', category: 'monitoring', description: 'Service status monitoring' },
|
||||||
|
'ntfy': { icon: 'bell', category: 'monitoring', description: 'Push notifications server' },
|
||||||
|
'monitoring stack': { icon: 'activity', category: 'monitoring', description: 'Prometheus & Grafana monitoring' },
|
||||||
|
'grafana': { icon: 'activity', category: 'monitoring', description: 'Metrics dashboard' },
|
||||||
|
'prometheus': { icon: 'activity', category: 'monitoring', description: 'Metrics collection' },
|
||||||
|
|
||||||
|
// Security
|
||||||
|
'vaultwarden': { icon: 'lock', category: 'security', description: 'Password manager' },
|
||||||
|
'authentik': { icon: 'shield', category: 'security', description: 'Identity provider & SSO' },
|
||||||
|
|
||||||
|
// Mail
|
||||||
|
'stalwart': { icon: 'bell', category: 'automation', description: 'Email server' },
|
||||||
|
'stalwart mail': { icon: 'bell', category: 'automation', description: 'Email server' },
|
||||||
|
'snappymail': { icon: 'bell', category: 'automation', description: 'Webmail client' },
|
||||||
|
|
||||||
|
// Databases
|
||||||
|
'postgresql': { icon: 'database', category: 'development', description: 'PostgreSQL database' },
|
||||||
|
'postgres': { icon: 'database', category: 'development', description: 'PostgreSQL database' },
|
||||||
|
'mysql': { icon: 'database', category: 'development', description: 'MySQL database' },
|
||||||
|
'redis': { icon: 'database', category: 'development', description: 'Redis cache' },
|
||||||
|
'mariadb': { icon: 'database', category: 'development', description: 'MariaDB database' },
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
'googlescraper': { icon: 'search', category: 'automation', description: 'Google scraper API' },
|
||||||
|
'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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultMeta: ServiceMeta = {
|
||||||
|
icon: 'box',
|
||||||
|
category: 'infrastructure',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up metadata for a service by its name.
|
||||||
|
* Tries exact match first, then substring matching against registry keys.
|
||||||
|
*/
|
||||||
|
export function lookupService(name: string): ServiceMeta {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (registry[lower]) {
|
||||||
|
return registry[lower];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try matching a registry key as a substring of the name
|
||||||
|
for (const [key, meta] of Object.entries(registry)) {
|
||||||
|
if (lower.includes(key)) {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer category from resource type for databases
|
||||||
|
return defaultMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata for a database resource based on its type string from Coolify.
|
||||||
|
*/
|
||||||
|
export function lookupDatabase(type: string, name: string): ServiceMeta {
|
||||||
|
const lower = type.toLowerCase();
|
||||||
|
if (lower.includes('postgresql') || lower.includes('postgres')) {
|
||||||
|
return { icon: 'database', category: 'development', description: 'PostgreSQL database' };
|
||||||
|
}
|
||||||
|
if (lower.includes('mysql') || lower.includes('mariadb')) {
|
||||||
|
return { icon: 'database', category: 'development', description: 'MySQL database' };
|
||||||
|
}
|
||||||
|
if (lower.includes('redis')) {
|
||||||
|
return { icon: 'database', category: 'development', description: 'Redis cache' };
|
||||||
|
}
|
||||||
|
if (lower.includes('mongo')) {
|
||||||
|
return { icon: 'database', category: 'development', description: 'MongoDB database' };
|
||||||
|
}
|
||||||
|
// Fall back to name-based lookup
|
||||||
|
return lookupService(name);
|
||||||
|
}
|
||||||
169
nuc-portal/src/lib/services.ts
Normal file
169
nuc-portal/src/lib/services.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
export type ServiceCategory = 'infrastructure' | 'development' | 'knowledge' | 'storage' | 'monitoring' | 'security' | 'automation';
|
||||||
|
export type BookmarkCategory = 'developer' | 'ai-tools' | 'ai-platforms' | 'utilities' | 'design' | 'learning' | 'productivity' | 'other';
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
port: number;
|
||||||
|
icon: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
description?: string;
|
||||||
|
container?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredService extends Service {
|
||||||
|
source: 'discovered' | 'static';
|
||||||
|
fqdn?: string;
|
||||||
|
resourceType: 'application' | 'service' | 'database';
|
||||||
|
uuid: 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 {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
category: BookmarkCategory;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { clientConfig, getServiceUrl } from './config';
|
||||||
|
|
||||||
|
const h = clientConfig.nucHost;
|
||||||
|
|
||||||
|
export const fallbackServices: Service[] = [
|
||||||
|
// Infrastructure - prefer domain-based URLs
|
||||||
|
{ name: 'Coolify', url: 'http://coolify.nuc.lan', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
|
||||||
|
{ 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' },
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
{ name: 'n8n', url: `http://${h}:5678`, port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' },
|
||||||
|
|
||||||
|
// Development - prefer domain-based URLs
|
||||||
|
{ 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: '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 - prefer domain-based URLs
|
||||||
|
{ 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' },
|
||||||
|
|
||||||
|
// Storage - prefer domain-based URLs
|
||||||
|
{ 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: 'Kopia', url: `http://${h}:51515`, port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' },
|
||||||
|
|
||||||
|
// 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' },
|
||||||
|
|
||||||
|
// Security - prefer domain-based URLs
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Re-export for backwards compatibility
|
||||||
|
export const services = fallbackServices;
|
||||||
|
|
||||||
|
export const bookmarks: Bookmark[] = [
|
||||||
|
// Developer Tools
|
||||||
|
{ name: 'DevDocs', url: 'https://devdocs.io', icon: 'book', category: 'developer', description: 'API documentation browser' },
|
||||||
|
{ name: 'Can I Use', url: 'https://caniuse.com', icon: 'check-circle', category: 'developer', description: 'Browser compatibility tables' },
|
||||||
|
{ name: 'Regex101', url: 'https://regex101.com', icon: 'brackets', category: 'developer', description: 'Regex tester & debugger' },
|
||||||
|
{ name: 'Bundlephobia', url: 'https://bundlephobia.com', icon: 'package', category: 'developer', description: 'NPM package size analyzer' },
|
||||||
|
{ name: 'Transform Tools', url: 'https://transform.tools', icon: 'arrow-right-left', category: 'developer', description: 'Code transformers' },
|
||||||
|
|
||||||
|
// AI Tools
|
||||||
|
{ name: 'Claude', url: 'https://claude.ai', icon: 'bot', category: 'ai-tools', description: 'Anthropic AI assistant' },
|
||||||
|
{ name: 'ChatGPT', url: 'https://chat.openai.com', icon: 'message-square', category: 'ai-tools', description: 'OpenAI chat assistant' },
|
||||||
|
{ name: 'Perplexity', url: 'https://perplexity.ai', icon: 'search', category: 'ai-tools', description: 'AI-powered search' },
|
||||||
|
{ name: 'Phind', url: 'https://phind.com', icon: 'code', category: 'ai-tools', description: 'AI for developers' },
|
||||||
|
{ name: 'Cursor', url: 'https://cursor.com', icon: 'terminal', category: 'ai-tools', description: 'AI-first code editor' },
|
||||||
|
|
||||||
|
// AI Platforms
|
||||||
|
{ name: 'v0', url: 'https://v0.dev', icon: 'layout', category: 'ai-platforms', description: 'Vercel AI UI generator' },
|
||||||
|
{ name: 'Replicate', url: 'https://replicate.com', icon: 'cpu', category: 'ai-platforms', description: 'ML model hosting' },
|
||||||
|
{ name: 'Hugging Face', url: 'https://huggingface.co', icon: 'smile', category: 'ai-platforms', description: 'ML models & datasets' },
|
||||||
|
{ name: 'Together AI', url: 'https://together.ai', icon: 'users', category: 'ai-platforms', description: 'Open model inference' },
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
{ name: 'Excalidraw', url: 'https://excalidraw.com', icon: 'pencil', category: 'utilities', description: 'Hand-drawn diagrams' },
|
||||||
|
{ name: 'JSON Crack', url: 'https://jsoncrack.com', icon: 'braces', category: 'utilities', description: 'JSON visualizer' },
|
||||||
|
{ name: 'Carbon', url: 'https://carbon.now.sh', icon: 'image', category: 'utilities', description: 'Code screenshot tool' },
|
||||||
|
{ name: 'Squoosh', url: 'https://squoosh.app', icon: 'image-down', category: 'utilities', description: 'Image compression' },
|
||||||
|
{ name: 'TinyPNG', url: 'https://tinypng.com', icon: 'file-image', category: 'utilities', description: 'PNG/JPEG compression' },
|
||||||
|
|
||||||
|
// Design
|
||||||
|
{ name: 'Figma', url: 'https://figma.com', icon: 'figma', category: 'design', description: 'Collaborative design tool' },
|
||||||
|
{ name: 'Coolors', url: 'https://coolors.co', icon: 'palette', category: 'design', description: 'Color palette generator' },
|
||||||
|
{ name: 'Heroicons', url: 'https://heroicons.com', icon: 'shapes', category: 'design', description: 'Beautiful hand-crafted icons' },
|
||||||
|
{ name: 'Lucide', url: 'https://lucide.dev', icon: 'circle', category: 'design', description: 'Icon library' },
|
||||||
|
|
||||||
|
// Learning
|
||||||
|
{ name: 'MDN Web Docs', url: 'https://developer.mozilla.org', icon: 'graduation-cap', category: 'learning', description: 'Web technology docs' },
|
||||||
|
{ name: 'web.dev', url: 'https://web.dev', icon: 'globe', category: 'learning', description: 'Modern web guidance' },
|
||||||
|
|
||||||
|
// Productivity
|
||||||
|
{ name: 'Linear', url: 'https://linear.app', icon: 'list-todo', category: 'productivity', description: 'Issue tracking' },
|
||||||
|
{ name: 'Notion', url: 'https://notion.so', icon: 'notebook', category: 'productivity', description: 'Notes & docs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const categoryLabels: Record<ServiceCategory, string> = {
|
||||||
|
infrastructure: 'Infrastructure',
|
||||||
|
development: 'Development',
|
||||||
|
knowledge: 'Knowledge',
|
||||||
|
storage: 'Storage',
|
||||||
|
monitoring: 'Monitoring',
|
||||||
|
security: 'Security',
|
||||||
|
automation: 'Automation',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bookmarkCategoryLabels: Record<BookmarkCategory, string> = {
|
||||||
|
developer: 'Developer Tools',
|
||||||
|
'ai-tools': 'AI Tools',
|
||||||
|
'ai-platforms': 'AI Platforms',
|
||||||
|
utilities: 'Utilities',
|
||||||
|
design: 'Design',
|
||||||
|
learning: 'Learning',
|
||||||
|
productivity: 'Productivity',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categoryOrder: ServiceCategory[] = [
|
||||||
|
'infrastructure',
|
||||||
|
'automation',
|
||||||
|
'development',
|
||||||
|
'knowledge',
|
||||||
|
'storage',
|
||||||
|
'monitoring',
|
||||||
|
'security',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bookmarkCategoryOrder: BookmarkCategory[] = [
|
||||||
|
'developer',
|
||||||
|
'ai-tools',
|
||||||
|
'ai-platforms',
|
||||||
|
'utilities',
|
||||||
|
'design',
|
||||||
|
'learning',
|
||||||
|
'productivity',
|
||||||
|
'other',
|
||||||
|
];
|
||||||
57
nuc-portal/src/lib/stats.ts
Normal file
57
nuc-portal/src/lib/stats.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface SystemStats {
|
||||||
|
cpu_percent: number;
|
||||||
|
ram_total_mb: number;
|
||||||
|
ram_used_mb: number;
|
||||||
|
ram_percent: number;
|
||||||
|
swap_total_mb: number;
|
||||||
|
swap_used_mb: number;
|
||||||
|
swap_percent: number;
|
||||||
|
disk_total_gb: number;
|
||||||
|
disk_used_gb: number;
|
||||||
|
disk_percent: number;
|
||||||
|
uptime_seconds: number;
|
||||||
|
load_avg: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVitalsColor(percent: number): string {
|
||||||
|
if (percent >= 90) return 'text-red-500';
|
||||||
|
if (percent >= 70) return 'text-amber-500';
|
||||||
|
return 'text-emerald-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVitalsBg(percent: number): string {
|
||||||
|
if (percent >= 90) return 'bg-red-500';
|
||||||
|
if (percent >= 70) return 'bg-amber-500';
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVitalsTrack(percent: number): string {
|
||||||
|
if (percent >= 90) return 'bg-red-500/20';
|
||||||
|
if (percent >= 70) return 'bg-amber-500/20';
|
||||||
|
return 'bg-emerald-500/20';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-series metrics from Prometheus
|
||||||
|
export type MetricSeries = Array<[number, number]>; // [timestamp, value]
|
||||||
|
|
||||||
|
export interface MetricsData {
|
||||||
|
cpu: MetricSeries;
|
||||||
|
ram: MetricSeries;
|
||||||
|
netRx: MetricSeries;
|
||||||
|
netTx: MetricSeries;
|
||||||
|
temp: MetricSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB/s`;
|
||||||
|
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB/s`;
|
||||||
|
return `${Math.round(bytes)} B/s`;
|
||||||
|
}
|
||||||
133
nuc-portal/src/lib/useEventStream.ts
Normal file
133
nuc-portal/src/lib/useEventStream.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { DiscoveredService } from './services';
|
||||||
|
import type { Deployment } from './deployments';
|
||||||
|
import type { SystemStats, MetricsData } from './stats';
|
||||||
|
|
||||||
|
interface ActiveDeployLog {
|
||||||
|
uuid: string;
|
||||||
|
logs: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventStreamState {
|
||||||
|
services: DiscoveredService[];
|
||||||
|
stats: SystemStats | null;
|
||||||
|
deployments: Deployment[];
|
||||||
|
metrics: MetricsData | null;
|
||||||
|
activeDeployLogs: ActiveDeployLog[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventStream() {
|
||||||
|
const [state, setState] = useState<EventStreamState>({
|
||||||
|
services: [],
|
||||||
|
stats: null,
|
||||||
|
deployments: [],
|
||||||
|
metrics: null,
|
||||||
|
activeDeployLogs: [],
|
||||||
|
connected: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryDelay = useRef(1000);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
retryDelay.current = 1000;
|
||||||
|
setState(prev => ({ ...prev, connected: true, error: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setState(prev => ({ ...prev, connected: false }));
|
||||||
|
|
||||||
|
const delay = retryDelay.current;
|
||||||
|
retryDelay.current = Math.min(delay * 2, 30000);
|
||||||
|
setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener('services', (e) => {
|
||||||
|
try {
|
||||||
|
const services = JSON.parse(e.data);
|
||||||
|
setState(prev => ({ ...prev, services }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('stats', (e) => {
|
||||||
|
try {
|
||||||
|
const stats = JSON.parse(e.data);
|
||||||
|
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 */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('deployments', (e) => {
|
||||||
|
try {
|
||||||
|
const deployments = JSON.parse(e.data);
|
||||||
|
setState(prev => ({ ...prev, deployments }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('metrics', (e) => {
|
||||||
|
try {
|
||||||
|
const metrics = JSON.parse(e.data);
|
||||||
|
setState(prev => ({ ...prev, metrics }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('deployment:logs', (e) => {
|
||||||
|
try {
|
||||||
|
const activeDeployLogs = JSON.parse(e.data);
|
||||||
|
setState(prev => ({ ...prev, activeDeployLogs }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('heartbeat', () => {
|
||||||
|
// Keep-alive; no state change needed
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
34
nuc-portal/tsconfig.json
Normal file
34
nuc-portal/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user