Initial commit: Nexus Dashboard v1.0

This commit is contained in:
Bastian 2026-02-01 18:42:22 +00:00
commit 8569bc3913
46 changed files with 9950 additions and 0 deletions

41
.gitignore vendored Normal file
View 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

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyCredentials, createToken } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password required' },
{ status: 400 }
);
}
const isValid = await verifyCredentials(username, password);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const token = createToken(username);
const response = NextResponse.json(
{ success: true, username },
{ status: 200 }
);
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const response = NextResponse.json(
{ success: true },
{ status: 200 }
);
response.cookies.delete('auth-token');
return response;
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ authenticated: false },
{ status: 401 }
);
}
return NextResponse.json(
{ authenticated: true, username: user },
{ status: 200 }
);
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken, createToken } from '@/lib/auth';
export async function GET(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.json({
hasCookie: false,
message: 'No auth-token cookie found'
});
}
const payload = verifyToken(token);
return NextResponse.json({
hasCookie: true,
tokenPreview: token.substring(0, 50) + '...',
payload: payload,
isValid: payload !== null,
});
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { controlVM } from '@/lib/ssh';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { action } = await request.json();
if (!['start', 'stop', 'restart'].includes(action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be start, stop, or restart' },
{ status: 400 }
);
}
await controlVM(id, action as 'start' | 'stop' | 'restart');
return NextResponse.json({
success: true,
message: `VM ${action} command sent successfully`
});
} catch (error) {
console.error('Error controlling VM:', error);
return NextResponse.json(
{ error: 'Failed to control VM' },
{ status: 500 }
);
}
}

49
app/api/vms/[id]/route.ts Normal file
View File

@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { getVMConfig, getVMStatus, getVMStats, getVMList } from '@/lib/ssh';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get VM basic info from list
const vms = await getVMList();
const vm = vms.find(v => v.vmid === id);
if (!vm) {
return NextResponse.json(
{ error: 'VM not found' },
{ status: 404 }
);
}
// Get detailed config
const config = await getVMConfig(id);
const status = await getVMStatus(id);
// Get stats only if VM is running
let stats = { cpu: 0, mem: { used: 0, total: 0, percent: 0 }, disk: { percent: 0 } };
if (status === 'running') {
try {
stats = await getVMStats(id, vm.name);
} catch (error) {
console.error('Error fetching stats:', error);
}
}
return NextResponse.json({
...vm,
config,
status,
stats
});
} catch (error) {
console.error('Error fetching VM details:', error);
return NextResponse.json(
{ error: 'Failed to fetch VM details' },
{ status: 500 }
);
}
}

18
app/api/vms/route.ts Normal file
View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getVMList } from '@/lib/ssh';
export async function GET() {
try {
console.log('[API] Fetching VM list...');
const vms = await getVMList();
console.log('[API] VMs fetched successfully:', vms.length);
return NextResponse.json({ vms });
} catch (error) {
console.error('[API] Error fetching VMs:', error);
console.error('[API] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
return NextResponse.json(
{ error: 'Failed to fetch VMs', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

43
app/globals.css Normal file
View File

@ -0,0 +1,43 @@
@import "tailwindcss";
:root {
--background: #000000;
--foreground: #FAFAFA;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Inter, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus visible for accessibility */
*:focus-visible {
outline: 2px solid white;
outline-offset: 2px;
}
/* Custom scrollbar (Apple-like) */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0A0A0A;
}
::-webkit-scrollbar-thumb {
background: #262626;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #404040;
}

29
app/layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import './globals.css';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifyToken } from '@/lib/auth';
export const metadata: Metadata = {
title: 'Nexus - VM Control Center',
description: 'Secure VM monitoring and management platform',
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
const pathname = typeof window !== 'undefined' ? window.location.pathname : '';
// Check if we're on login page (we can't use usePathname in server component)
// So we'll handle this in the login page itself
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}

151
app/login/page.tsx Normal file
View File

@ -0,0 +1,151 @@
'use client';
import { useState, FormEvent } from 'react';
import { Lock } from 'lucide-react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include',
});
if (res.status === 429) {
setError('Too many login attempts. Please wait a moment and try again.');
setLoading(false);
return;
}
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed');
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
setTimeout(() => {
window.location.replace('/');
}, 500);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
setError(`Network error: ${errorMsg}`);
setLoading(false);
}
};
if (success) {
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<Card className="w-full max-w-sm text-center" hover={false}>
<div className="mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-success/10 flex items-center justify-center">
<Lock className="w-8 h-8 text-success" />
</div>
</div>
<h2 className="text-2xl font-bold text-foreground mb-4">Login Successful!</h2>
<p className="text-foreground-muted mb-6">Redirecting to dashboard...</p>
<a
href="/"
className="inline-block bg-foreground text-background hover:bg-foreground/90 font-semibold py-3 px-6 rounded-xl transition-all duration-200 hover:scale-[1.02]"
>
Click here if not redirected
</a>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<Card className="w-full max-w-sm" hover={false}>
<div className="text-center mb-8">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-foreground/5 flex items-center justify-center">
<Lock className="w-6 h-6 text-foreground" />
</div>
<h1 className="text-4xl font-bold mb-2 text-foreground">
NEXUS
</h1>
<p className="text-foreground-muted text-sm">VM Control Center</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-danger/10 border border-danger/30 rounded-xl p-3">
<p className="text-danger text-sm text-center">{error}</p>
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground-muted mb-2">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder-foreground-subtle focus:outline-none focus:border-foreground focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-background transition-colors disabled:opacity-50"
placeholder="Enter username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground-muted mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder-foreground-subtle focus:outline-none focus:border-foreground focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-background transition-colors disabled:opacity-50"
placeholder="Enter password"
/>
</div>
<Button
type="submit"
disabled={loading}
variant="primary"
size="lg"
className="w-full"
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-foreground-subtle text-xs">
Secure access only
</p>
</div>
</Card>
</div>
);
}

21
app/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifyToken } from '@/lib/auth';
import Dashboard from '@/components/Dashboard';
export default async function Home() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
if (!token) {
redirect('/login');
}
const payload = verifyToken(token);
if (!payload) {
redirect('/login');
}
return <Dashboard />;
}

View File

@ -0,0 +1,36 @@
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifyToken } from '@/lib/auth';
import TerminalClient from '@/components/TerminalClient';
export default async function TerminalPage({ params }: { params: Promise<{ vmid: string }> }) {
const { vmid } = await params;
// Auth check
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
if (!token) {
redirect('/login');
}
const payload = verifyToken(token);
if (!payload) {
redirect('/login');
}
// Only allow VM 100
if (vmid !== '100') {
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-gray-400">Console access is only available for VM 100</p>
</div>
</div>
);
}
return <TerminalClient vmid={vmid} token={token} />;
}

23
app/test-page/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { cookies } from 'next/headers';
export default async function TestPage() {
const cookieStore = await cookies();
const authToken = cookieStore.get('auth-token');
return (
<div className="min-h-screen bg-gray-900 text-white p-8">
<h1 className="text-2xl mb-4">Cookie Test Page</h1>
<div className="bg-gray-800 p-4 rounded">
<p className="mb-2">Auth Token Cookie:</p>
{authToken ? (
<div>
<p className="text-green-400"> Cookie found!</p>
<p className="text-xs font-mono mt-2">{authToken.value.substring(0, 100)}...</p>
</div>
) : (
<p className="text-red-400"> No cookie found</p>
)}
</div>
</div>
);
}

252
app/vm/[id]/page.tsx Normal file
View File

@ -0,0 +1,252 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import StatusBadge from '@/components/StatusBadge';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Skeleton from '@/components/ui/Skeleton';
import { ArrowLeft, Play, Square, RotateCw, HardDrive, Cpu, MemoryStick } from 'lucide-react';
interface VMDetails {
vmid: string;
name: string;
status: string;
mem: string;
bootdisk: string;
config: Record<string, string>;
stats: {
cpu: number;
mem: {
used: number;
total: number;
percent: number;
};
disk: {
percent: number;
};
};
}
export default function VMDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const resolvedParams = use(params);
const router = useRouter();
const [vm, setVm] = useState<VMDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const fetchVMDetails = async () => {
try {
const res = await fetch(`/api/vms/${resolvedParams.id}`);
if (!res.ok) throw new Error('Failed to fetch VM details');
const data = await res.json();
setVm(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
setActionLoading(true);
try {
const res = await fetch(`/api/vms/${resolvedParams.id}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
if (!res.ok) throw new Error('Failed to perform action');
setToast({ message: `VM ${action}ing...`, type: 'success' });
setTimeout(() => setToast(null), 3000);
setTimeout(fetchVMDetails, 2000);
} catch (err) {
setToast({ message: err instanceof Error ? err.message : 'Action failed', type: 'error' });
setTimeout(() => setToast(null), 3000);
} finally {
setActionLoading(false);
}
};
useEffect(() => {
fetchVMDetails();
const interval = setInterval(fetchVMDetails, 5000);
return () => clearInterval(interval);
}, [resolvedParams.id]);
if (loading) {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 sm:px-6 py-8">
<Skeleton className="h-10 w-32 mb-6" />
<Skeleton className="h-24 w-full mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
if (error || !vm) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card hover={false} className="max-w-md">
<div className="bg-danger/10 border border-danger/30 rounded-xl p-6">
<p className="text-danger">Error: {error || 'VM not found'}</p>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-background text-foreground">
{/* Toast Notification */}
{toast && (
<div className="fixed top-4 right-4 z-50 animate-slide-up">
<div className={`${toast.type === 'success' ? 'bg-success/10 border-success/30 text-success' : 'bg-danger/10 border-danger/30 text-danger'} border rounded-xl px-4 py-3 shadow-elevated`}>
{toast.message}
</div>
</div>
)}
<div className="container mx-auto px-4 sm:px-6 py-8">
{/* Back Button */}
<Button
variant="ghost"
size="sm"
icon={ArrowLeft}
onClick={() => router.push('/')}
className="mb-6"
>
Back to Dashboard
</Button>
{/* VM Header */}
<Card hover={false} className="mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl sm:text-4xl font-bold text-foreground mb-2">{vm.name}</h1>
<p className="text-foreground-muted">VM ID: {vm.vmid}</p>
</div>
<StatusBadge status={vm.status} />
</div>
</Card>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-6">
<Card hover={false}>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
<Cpu className="w-5 h-5 text-foreground-muted" />
</div>
<h3 className="text-sm text-foreground-muted">CPU Usage</h3>
</div>
<p className="text-3xl font-bold text-foreground mb-3">{vm.stats.cpu}%</p>
<div className="h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
style={{ width: `${vm.stats.cpu}%` }}
/>
</div>
</Card>
<Card hover={false}>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
<MemoryStick className="w-5 h-5 text-foreground-muted" />
</div>
<h3 className="text-sm text-foreground-muted">Memory Usage</h3>
</div>
<p className="text-3xl font-bold text-foreground mb-1">{vm.stats.mem.percent}%</p>
<p className="text-xs text-foreground-subtle mb-3">
{vm.stats.mem.used} MB / {vm.stats.mem.total} MB
</p>
<div className="h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
style={{ width: `${vm.stats.mem.percent}%` }}
/>
</div>
</Card>
<Card hover={false}>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center">
<HardDrive className="w-5 h-5 text-foreground-muted" />
</div>
<h3 className="text-sm text-foreground-muted">Disk Usage</h3>
</div>
<p className="text-3xl font-bold text-foreground mb-3">{vm.stats.disk.percent}%</p>
<div className="h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-foreground to-foreground-muted transition-all duration-500"
style={{ width: `${vm.stats.disk.percent}%` }}
/>
</div>
</Card>
</div>
{/* Control Panel */}
<Card hover={false} className="mb-6">
<h2 className="text-xl font-bold text-foreground mb-4">Control Panel</h2>
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={() => handleAction('start')}
disabled={actionLoading || vm.status === 'running'}
variant="secondary"
icon={Play}
className="flex-1 border-success/30 hover:bg-success/10 text-success"
>
Start
</Button>
<Button
onClick={() => handleAction('stop')}
disabled={actionLoading || vm.status !== 'running'}
variant="danger"
icon={Square}
className="flex-1"
>
Stop
</Button>
<Button
onClick={() => handleAction('restart')}
disabled={actionLoading || vm.status !== 'running'}
variant="secondary"
icon={RotateCw}
className="flex-1"
>
Restart
</Button>
</div>
</Card>
{/* Configuration */}
<Card hover={false}>
<h2 className="text-xl font-bold text-foreground mb-4">Configuration</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.entries(vm.config).map(([key, value]) => (
<div key={key} className="pb-3 border-b border-border">
<p className="text-sm text-foreground-muted mb-1">{key}</p>
<p className="text-foreground font-mono text-sm">{value}</p>
</div>
))}
</div>
</Card>
</div>
</div>
);
}

102
components/Dashboard.tsx Normal file
View File

@ -0,0 +1,102 @@
'use client';
import { useEffect, useState } from 'react';
import VMCard from '@/components/VMCard';
import UserMenu from '@/components/UserMenu';
import Skeleton from '@/components/ui/Skeleton';
interface VM {
vmid: string;
name: string;
status: string;
mem: string;
bootdisk: string;
}
export default function Dashboard() {
const [vms, setVms] = useState<VM[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchVMs = async () => {
try {
const res = await fetch('/api/vms');
if (!res.ok) throw new Error('Failed to fetch VMs');
const data = await res.json();
setVms(data.vms);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchVMs();
const interval = setInterval(fetchVMs, 5000);
return () => clearInterval(interval);
}, []);
const runningCount = vms.filter(vm => vm.status === 'running').length;
return (
<div className="min-h-screen bg-background text-foreground">
{/* Header */}
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 sm:px-6 py-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center justify-between sm:justify-start gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">
NEXUS
</h1>
<p className="text-sm text-foreground-muted hidden sm:block">VM Control Center</p>
</div>
{!loading && (
<div className="flex items-center gap-3 text-sm text-foreground-muted">
<span className="flex items-center gap-1.5">
<span className="text-foreground font-semibold">{vms.length}</span>
VMs
</span>
<span className="text-foreground-subtle">·</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-success animate-pulse" />
<span className="text-foreground font-semibold">{runningCount}</span>
Running
</span>
</div>
)}
</div>
<UserMenu />
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 py-8">
{error && (
<div className="bg-danger/10 border border-danger/30 rounded-xl p-4 mb-6">
<p className="text-danger">Error: {error}</p>
</div>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in">
{vms.map((vm) => (
<VMCard key={vm.vmid} vm={vm} />
))}
</div>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,31 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LogoutButton() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleLogout = async () => {
setLoading(true);
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
setLoading(false);
}
};
return (
<button
onClick={handleLogout}
disabled={loading}
className="bg-red-600 hover:bg-red-700 disabled:bg-gray-600 px-4 py-2 rounded-lg transition-colors text-white font-medium"
>
{loading ? 'Logging out...' : 'Logout'}
</button>
);
}

View File

@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
interface StatusBadgeProps {
status: string
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const isRunning = status === 'running'
return (
<span
className={cn(
'inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium',
isRunning
? 'bg-success/10 text-success border border-success/30'
: 'bg-foreground-subtle/10 text-foreground-muted border border-foreground-subtle/30'
)}
>
{isRunning ? (
<span className="w-2 h-2 rounded-full bg-success animate-pulse" />
) : (
<span className="w-2 h-2 rounded-full border border-foreground-muted" />
)}
{status}
</span>
)
}

View File

@ -0,0 +1,219 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { io, Socket } from 'socket.io-client';
import '@xterm/xterm/css/xterm.css';
interface TerminalClientProps {
vmid: string;
token: string;
}
export default function TerminalClient({ vmid, token }: TerminalClientProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
const [error, setError] = useState<string | null>(null);
const termRef = useRef<Terminal | null>(null);
const socketRef = useRef<Socket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize Terminal
const term = new Terminal({
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467C',
black: '#15161E',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, Courier New, monospace',
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
// Give the terminal time to render before fitting
setTimeout(() => {
fitAddon.fit();
console.log(`[Terminal] Terminal fitted to: ${term.cols}x${term.rows}`);
}, 50);
termRef.current = term;
fitAddonRef.current = fitAddon;
let handleResize: (() => void) | null = null;
// Wait a bit for terminal to be properly sized, then connect
setTimeout(() => {
// Connect to WebSocket server via Nginx proxy
console.log('[Terminal] Connecting to WebSocket server...');
console.log(`[Terminal] Initial terminal size: ${term.cols}x${term.rows}`);
const socket = io({
auth: { token },
query: {
vmid,
cols: term.cols,
rows: term.rows
},
transports: ['websocket', 'polling'],
path: '/socket.io/',
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('[Terminal] Connected to WebSocket');
setStatus('connecting');
});
socket.on('status', (newStatus: string) => {
console.log('[Terminal] Status:', newStatus);
if (newStatus === 'connected') {
setStatus('connected');
setError(null);
// Send initial terminal size when SSH is ready
if (fitAddon) {
fitAddon.fit();
const rows = term.rows;
const cols = term.cols;
console.log(`[Terminal] Sending initial size: ${cols}x${rows}`);
socket.emit('resize', { rows, cols });
}
} else if (newStatus === 'disconnected') {
setStatus('disconnected');
}
});
socket.on('data', (data: string) => {
term.write(data);
});
socket.on('error', (err: string) => {
console.error('[Terminal] Error:', err);
setError(err);
setStatus('error');
});
socket.on('disconnect', () => {
console.log('[Terminal] Disconnected from WebSocket');
setStatus('disconnected');
});
// Terminal → WebSocket
term.onData((data) => {
if (socket.connected) {
socket.emit('data', data);
}
});
// Handle terminal resize
handleResize = () => {
if (fitAddon && socket.connected) {
fitAddon.fit();
socket.emit('resize', {
rows: term.rows,
cols: term.cols,
});
}
};
window.addEventListener('resize', handleResize);
// Fit terminal after a short delay
setTimeout(handleResize, 100);
}, 100); // Wait 100ms for terminal to render
// Cleanup
return () => {
if (handleResize) {
window.removeEventListener('resize', handleResize);
}
if (socketRef.current) {
socketRef.current.disconnect();
}
term.dispose();
};
}, [vmid, token]);
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
{/* Header */}
<div className="bg-background-elevated px-4 py-3 flex justify-between items-center border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-foreground font-bold text-lg">VM {vmid} Console</h1>
<div className="flex items-center gap-2">
{status === 'connecting' && (
<div className="flex items-center gap-2 text-warning text-sm">
<div className="w-2 h-2 bg-warning rounded-full animate-pulse"></div>
<span>Connecting...</span>
</div>
)}
{status === 'connected' && (
<div className="flex items-center gap-2 text-success text-sm">
<div className="w-2 h-2 bg-success rounded-full"></div>
<span>Connected</span>
</div>
)}
{status === 'disconnected' && (
<div className="flex items-center gap-2 text-danger text-sm">
<div className="w-2 h-2 bg-danger rounded-full"></div>
<span>Disconnected</span>
</div>
)}
{status === 'error' && error && (
<div className="flex items-center gap-2 text-danger text-sm">
<div className="w-2 h-2 bg-danger rounded-full"></div>
<span>{error}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<a
href="/"
className="bg-foreground hover:bg-foreground/90 text-foreground px-3 py-1 rounded text-sm transition-colors"
>
Back to Dashboard
</a>
</div>
</div>
{/* Terminal */}
<div className="flex-1 bg-[#1a1b26] overflow-hidden">
<div
ref={terminalRef}
className="w-full h-full"
/>
</div>
</div>
);
}

68
components/UserMenu.tsx Normal file
View File

@ -0,0 +1,68 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { User, Settings, LogOut } from 'lucide-react';
export default function UserMenu() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleLogout = async () => {
setLoading(true);
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
} catch (error) {
console.error('Logout error:', error);
setLoading(false);
}
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="w-10 h-10 rounded-full border-2 border-foreground bg-background-card flex items-center justify-center text-foreground font-semibold text-sm hover:bg-background-elevated transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-background">
A
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-background-card border border-border rounded-xl p-1 shadow-elevated animate-fade-in z-50"
sideOffset={8}
align="end"
>
<DropdownMenu.Item
disabled
className="flex items-center gap-3 px-3 py-2 text-sm rounded-lg outline-none opacity-50 cursor-not-allowed"
>
<User className="w-4 h-4" />
<span>Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
className="flex items-center gap-3 px-3 py-2 text-sm rounded-lg outline-none opacity-50 cursor-not-allowed"
>
<Settings className="w-4 h-4" />
<span>Settings</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-border my-1" />
<DropdownMenu.Item
onClick={handleLogout}
disabled={loading}
className="flex items-center gap-3 px-3 py-2 text-sm rounded-lg outline-none text-danger hover:bg-danger/10 cursor-pointer transition-colors focus:bg-danger/10 disabled:opacity-50"
>
<LogOut className="w-4 h-4" />
<span>{loading ? 'Logging out...' : 'Logout'}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

67
components/VMCard.tsx Normal file
View File

@ -0,0 +1,67 @@
import Link from 'next/link';
import StatusBadge from './StatusBadge';
import { HardDrive, Cpu, MemoryStick, Info, Terminal } from 'lucide-react';
interface VM {
vmid: string;
name: string;
status: string;
mem: string;
bootdisk: string;
}
interface VMCardProps {
vm: VM;
}
export default function VMCard({ vm }: VMCardProps) {
const hasConsoleAccess = vm.vmid === '100';
return (
<div className="bg-background-card rounded-2xl border border-border p-6 hover:shadow-elevated hover:-translate-y-1 hover:border-foreground/20 transition-all duration-200">
{/* Header */}
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-foreground mb-1">{vm.name}</h3>
<p className="text-sm text-foreground-muted">ID: {vm.vmid}</p>
</div>
<StatusBadge status={vm.status} />
</div>
{/* Stats */}
<div className="space-y-3 mb-6">
<div className="flex items-center gap-3 text-sm">
<HardDrive className="w-4 h-4 text-foreground-subtle" />
<span className="text-foreground-muted">Storage</span>
<span className="ml-auto text-foreground font-medium">{vm.bootdisk} GB</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Cpu className="w-4 h-4 text-foreground-subtle" />
<span className="text-foreground-muted">Memory</span>
<span className="ml-auto text-foreground font-medium">{vm.mem} MB</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Link
href={`/vm/${vm.vmid}`}
className="flex-1 inline-flex items-center justify-center gap-2 bg-background-card text-foreground hover:bg-background-elevated border border-border px-4 py-2 rounded-xl text-sm font-medium transition-all hover:border-foreground/20"
>
<Info className="w-4 h-4" />
Details
</Link>
{hasConsoleAccess && (
<Link
href={`/terminal/${vm.vmid}`}
className="flex-1 inline-flex items-center justify-center gap-2 bg-foreground text-background hover:bg-foreground/90 px-4 py-2 rounded-xl text-sm font-medium transition-all hover:scale-[1.02]"
>
<Terminal className="w-4 h-4" />
Console
</Link>
)}
</div>
</div>
);
}

46
components/ui/Button.tsx Normal file
View File

@ -0,0 +1,46 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { type LucideIcon } from 'lucide-react'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
icon?: LucideIcon
children: React.ReactNode
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', icon: Icon, children, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
// Base styles
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:opacity-50 disabled:cursor-not-allowed',
// Variants
variant === 'primary' && 'bg-foreground text-background hover:bg-foreground/90 hover:scale-[1.02] active:scale-[0.98]',
variant === 'secondary' && 'bg-background-card text-foreground hover:bg-background-elevated border border-border',
variant === 'ghost' && 'hover:bg-background-card',
variant === 'danger' && 'bg-danger hover:bg-danger/90 text-white',
// Sizes
size === 'sm' && 'px-3 py-1.5 text-sm',
size === 'md' && 'px-4 py-2 text-base',
size === 'lg' && 'px-6 py-3 text-lg',
className
)}
{...props}
>
{Icon && <Icon className="w-4 h-4" />}
{children}
</button>
)
}
)
Button.displayName = 'Button'
export default Button

22
components/ui/Card.tsx Normal file
View File

@ -0,0 +1,22 @@
import { cn } from '@/lib/utils'
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
hover?: boolean
}
export default function Card({ className, children, hover = true, ...props }: CardProps) {
return (
<div
className={cn(
'bg-background-card rounded-2xl border border-border p-6',
'transition-all duration-200',
hover && 'hover:shadow-elevated hover:-translate-y-0.5 hover:border-foreground/20',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@ -0,0 +1,12 @@
import { cn } from '@/lib/utils'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
export default function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded-2xl bg-background-card', className)}
{...props}
/>
)
}

35
ecosystem.config.js Normal file
View File

@ -0,0 +1,35 @@
module.exports = {
apps: [
{
name: 'nexus',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/sites/nexus',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3000
}
},
{
name: 'nexus-terminal',
script: 'server.mjs',
cwd: '/var/www/sites/nexus',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '512M',
env: {
NODE_ENV: 'production',
TERMINAL_PORT: 3001,
JWT_SECRET: '4916c430ed2682c2f023762e47531d95c6e51c4c5ab4ca8af79f6f010e203cf9',
VM100_SSH_HOST: '192.168.178.129',
VM100_SSH_USER: 'basti',
VM100_SSH_PASSWORD: 'Ba12sti34+',
}
}
]
};

18
eslint.config.mjs Normal file
View 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;

68
lib/auth.ts Normal file
View File

@ -0,0 +1,68 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { cookies } from 'next/headers';
const JWT_SECRET = process.env.JWT_SECRET || '4916c430ed2682c2f023762e47531d95c6e51c4c5ab4ca8af79f6f010e203cf9';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH || '$2b$10$VNZQMk1mQDoHif6moT9ceuB1wMoB7VImq21LxQecf3mlTwIUGahBO';
export interface JWTPayload {
username: string;
iat: number;
exp: number;
}
/**
* Verify username and password
*/
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
if (username !== ADMIN_USERNAME) {
return false;
}
return bcrypt.compare(password, ADMIN_PASSWORD_HASH);
}
/**
* Create JWT token
*/
export function createToken(username: string): string {
return jwt.sign(
{ username },
JWT_SECRET,
{ expiresIn: '24h' }
);
}
/**
* Verify JWT token
*/
export function verifyToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
} catch (error) {
return null;
}
}
/**
* Get current user from cookies
*/
export async function getCurrentUser(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
if (!token) {
return null;
}
const payload = verifyToken(token);
return payload ? payload.username : null;
}
/**
* Check if user is authenticated
*/
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser();
return user !== null;
}

126
lib/ssh.ts Normal file
View File

@ -0,0 +1,126 @@
import { execSync } from 'child_process';
const SSH_CONFIG: Record<string, { host: string; user: string }> = {
'proxmox': { host: '192.168.178.32', user: 'root' },
'n8n': { host: '192.168.178.129', user: 'basti' },
'webserver': { host: '192.168.178.130', user: 'basti' }
};
const SSH_KEY_PATH = '/home/basti/.ssh/id_ed25519';
function executeSSHCommand(host: string, command: string): string {
try {
const config = SSH_CONFIG[host];
const sshHost = config ? `${config.user}@${config.host}` : host;
const fullCommand = `ssh -o StrictHostKeyChecking=no -i ${SSH_KEY_PATH} ${sshHost} "${command.replace(/"/g, '\\"')}"`;
const stdout = execSync(fullCommand, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
env: { ...process.env, HOME: '/home/basti' }
});
return stdout.trim();
} catch (error: any) {
console.error('SSH Command Error:', error.message);
throw new Error(`SSH command failed: ${error.message}`);
}
}
export async function getVMList() {
const output = executeSSHCommand('proxmox', 'qm list');
const lines = output.split('\n').slice(1); // Skip header
return lines
.map(line => {
const parts = line.trim().split(/\s+/);
if (!parts[0] || parts[0] === '') return null;
return {
vmid: parts[0],
name: parts[1] || 'unknown',
status: parts[2] || 'unknown',
mem: parts[3] || '0',
bootdisk: parts[4] || '0',
pid: parts[5] || null
};
})
.filter((vm): vm is NonNullable<typeof vm> => vm !== null);
}
export async function getVMConfig(vmid: string) {
const output = executeSSHCommand('proxmox', `qm config ${vmid}`);
const config: Record<string, string> = {};
output.split('\n').forEach(line => {
const [key, ...valueParts] = line.split(':');
if (key && valueParts.length) {
config[key.trim()] = valueParts.join(':').trim();
}
});
return config;
}
export async function getVMStatus(vmid: string) {
const output = executeSSHCommand('proxmox', `qm status ${vmid}`);
const match = output.match(/status:\s*(\w+)/);
return match ? match[1] : 'unknown';
}
export async function controlVM(vmid: string, action: 'start' | 'stop' | 'restart') {
return executeSSHCommand('proxmox', `qm ${action} ${vmid}`);
}
// Get VM stats via SSH
export async function getVMStats(vmid: string, vmName: string) {
try {
// Use the webserver directly since we're running on VM 200
if (vmName === 'webserver') {
const cpuCmd = execSync("top -bn1 | grep 'Cpu(s)' | awk '{print 100 - $8}'", { encoding: 'utf-8' });
const memCmd = execSync("free -m | awk 'NR==2{printf \"%d/%d\", $3, $2}'", { encoding: 'utf-8' });
const diskCmd = execSync("df -h / | awk 'NR==2{print $5}'", { encoding: 'utf-8' });
const [memUsed, memTotal] = memCmd.trim().split('/').map(Number);
return {
cpu: Math.round(parseFloat(cpuCmd.trim()) || 0),
mem: {
used: memUsed,
total: memTotal,
percent: Math.round((memUsed / memTotal) * 100)
},
disk: {
percent: parseInt(diskCmd.replace('%', '').trim()) || 0
}
};
}
// For other VMs, use SSH
const cpuCmd = executeSSHCommand(vmName, "top -bn1 | grep 'Cpu(s)' | awk '{print 100 - \\$8}'");
const memCmd = executeSSHCommand(vmName, "free -m | awk 'NR==2{printf \"%d/%d\", \\$3, \\$2}'");
const diskCmd = executeSSHCommand(vmName, "df -h / | awk 'NR==2{print \\$5}'");
const [memUsed, memTotal] = memCmd.split('/').map(Number);
return {
cpu: Math.round(parseFloat(cpuCmd) || 0),
mem: {
used: memUsed,
total: memTotal,
percent: Math.round((memUsed / memTotal) * 100)
},
disk: {
percent: parseInt(diskCmd.replace('%', '')) || 0
}
};
} catch (error) {
console.error('Error fetching VM stats:', error);
// If VM is not running or SSH fails, return zeros
return {
cpu: 0,
mem: { used: 0, total: 0, percent: 0 },
disk: { percent: 0 }
};
}
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

48
middleware.ts.backup Normal file
View File

@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const pathname = request.nextUrl.pathname;
console.log('[Middleware]', pathname, 'Token:', token ? 'YES' : 'NO');
// Check if user is trying to access login page
const isLoginPage = pathname === '/login';
// If no token and not on login page, redirect to login
if (!token && !isLoginPage) {
console.log('[Middleware] No token, redirecting to /login');
return NextResponse.redirect(new URL('/login', request.url));
}
// If token exists, verify it
if (token) {
const payload = verifyToken(token);
console.log('[Middleware] Token payload:', payload ? 'VALID' : 'INVALID');
// If token is invalid and not on login page, redirect to login
if (!payload && !isLoginPage) {
console.log('[Middleware] Invalid token, redirecting to /login');
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('auth-token');
return response;
}
// If token is valid and on login page, redirect to home
if (payload && isLoginPage) {
console.log('[Middleware] Valid token on login page, redirecting to /');
return NextResponse.redirect(new URL('/', request.url));
}
}
console.log('[Middleware] Allowing request to', pathname);
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7872
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "nexus",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/bcryptjs": "^2.4.6",
"@types/cookie": "^0.6.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/ssh2": "^1.15.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"cookie": "^1.1.1",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

155
server.mjs Normal file
View File

@ -0,0 +1,155 @@
// WebSocket Server for Terminal
// This runs as a separate process managed by PM2
import { createServer } from 'http';
import { Server } from 'socket.io';
import { Client } from 'ssh2';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || '4916c430ed2682c2f023762e47531d95c6e51c4c5ab4ca8af79f6f010e203cf9';
const VM100_SSH_HOST = process.env.VM100_SSH_HOST || '192.168.178.129';
const VM100_SSH_USER = process.env.VM100_SSH_USER || 'root';
const VM100_SSH_PASSWORD = process.env.VM100_SSH_PASSWORD || 'Ba12sti34+';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: '*',
credentials: true
},
path: '/socket.io/',
transports: ['websocket', 'polling']
});
// Authentication Middleware
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication error: No token provided'));
}
try {
const payload = jwt.verify(token, JWT_SECRET);
socket.data.user = payload.username;
next();
} catch (err) {
next(new Error('Authentication error: Invalid token'));
}
});
io.on('connection', (socket) => {
console.log(`[Terminal] User ${socket.data.user} connected (${socket.id})`);
const vmid = socket.handshake.query.vmid;
const initialCols = parseInt(socket.handshake.query.cols) || 80;
const initialRows = parseInt(socket.handshake.query.rows) || 24;
console.log(`[Terminal] Initial terminal size: ${initialCols}x${initialRows}`);
// Only allow VM 100
if (vmid !== '100') {
socket.emit('error', 'Access denied: Only VM 100 console is available');
socket.disconnect();
return;
}
let sshClient = null;
let sshStream = null;
// SSH Connection
const connectSSH = () => {
sshClient = new Client();
sshClient.on('ready', () => {
console.log(`[Terminal] SSH connected to VM ${vmid}`);
socket.emit('status', 'connected');
// Create shell with correct initial size
sshClient.shell({
term: 'xterm-256color',
cols: initialCols,
rows: initialRows
}, (err, stream) => {
if (err) {
socket.emit('error', `Shell error: ${err.message}`);
return;
}
sshStream = stream;
// SSH → Browser
stream.on('data', (data) => {
socket.emit('data', data.toString('utf-8'));
});
stream.on('close', () => {
console.log(`[Terminal] SSH stream closed for ${socket.id}`);
socket.emit('status', 'disconnected');
});
stream.stderr.on('data', (data) => {
socket.emit('data', data.toString('utf-8'));
});
// Browser → SSH
socket.on('data', (data) => {
if (sshStream && !sshStream.destroyed) {
stream.write(data);
}
});
// Terminal Resize
socket.on('resize', ({ rows, cols }) => {
console.log(`[Terminal] Resize request: ${cols}x${rows}`);
if (sshStream && !sshStream.destroyed) {
try {
stream.setWindow(rows, cols);
console.log(`[Terminal] Resized to: ${cols}x${rows}`);
} catch (err) {
console.error(`[Terminal] Resize failed: ${err.message}`);
}
}
});
});
});
sshClient.on('error', (err) => {
console.error(`[Terminal] SSH error: ${err.message}`);
socket.emit('error', `SSH connection failed: ${err.message}`);
});
sshClient.on('close', () => {
console.log(`[Terminal] SSH connection closed for ${socket.id}`);
});
// Connect to VM 100
sshClient.connect({
host: VM100_SSH_HOST,
port: 22,
username: VM100_SSH_USER,
password: VM100_SSH_PASSWORD,
readyTimeout: 10000,
});
};
// Start SSH connection
connectSSH();
// Handle disconnect
socket.on('disconnect', () => {
console.log(`[Terminal] User ${socket.data.user} disconnected (${socket.id})`);
if (sshStream) {
sshStream.end();
}
if (sshClient) {
sshClient.end();
}
});
});
const PORT = process.env.TERMINAL_PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`[Terminal] WebSocket server running on port ${PORT}`);
});

8
start.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
cd /var/www/sites/nexus
export NODE_ENV=production
export PORT=3000
export JWT_SECRET=4916c430ed2682c2f023762e47531d95c6e51c4c5ab4ca8af79f6f010e203cf9
export ADMIN_USERNAME=admin
export ADMIN_PASSWORD_HASH='$2b$10$VNZQMk1mQDoHif6moT9ceuB1wMoB7VImq21LxQecf3mlTwIUGahBO'
exec ./node_modules/.bin/next start

64
tailwind.config.ts Normal file
View File

@ -0,0 +1,64 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Monochrome Palette
background: {
DEFAULT: '#000000',
elevated: '#0A0A0A',
card: '#1A1A1A',
},
foreground: {
DEFAULT: '#FAFAFA',
muted: '#A1A1A1',
subtle: '#525252',
},
border: {
DEFAULT: '#262626',
muted: '#1A1A1A',
},
// Status Colors
success: '#10B981',
danger: '#EF4444',
warning: '#F59E0B',
},
fontFamily: {
sans: ['var(--font-geist-sans)', 'Inter', 'system-ui', 'sans-serif'],
mono: ['var(--font-geist-mono)', 'monospace'],
},
boxShadow: {
'soft': '0 2px 8px rgba(255, 255, 255, 0.05)',
'elevated': '0 8px 24px rgba(255, 255, 255, 0.08)',
'glow': '0 0 20px rgba(255, 255, 255, 0.15)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
},
},
plugins: [],
}
export default config

30
test-auth.js Normal file
View File

@ -0,0 +1,30 @@
const bcrypt = require('bcryptjs');
const fs = require('fs');
const password = 'Ba12sti34+';
bcrypt.hash(password, 10).then(hash => {
console.log('Generated hash:', hash);
// Test it immediately
bcrypt.compare(password, hash).then(match => {
console.log('Immediate verification:', match);
// Write to .env.local
const envContent = '# JWT Authentication\nJWT_SECRET=4916c430ed2682c2f023762e47531d95c6e51c4c5ab4ca8af79f6f010e203cf9\n\n# Admin Credentials\nADMIN_USERNAME=admin\nADMIN_PASSWORD_HASH=' + hash + '\n';
fs.writeFileSync('.env.local', envContent);
console.log('.env.local written');
// Read it back and verify
const readBack = fs.readFileSync('.env.local', 'utf8');
const hashMatch = readBack.match(/ADMIN_PASSWORD_HASH=(.*)/);
if (hashMatch) {
const storedHash = hashMatch[1].trim();
console.log('Stored hash:', storedHash);
bcrypt.compare(password, storedHash).then(finalMatch => {
console.log('Final verification from file:', finalMatch);
});
}
});
});

34
tsconfig.json Normal file
View 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": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}