Initial commit: Nexus Dashboard v1.0
This commit is contained in:
commit
8569bc3913
41
.gitignore
vendored
Normal file
41
.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
|
||||
36
README.md
Normal file
36
README.md
Normal 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.
|
||||
47
app/api/auth/login/route.ts
Normal file
47
app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
12
app/api/auth/logout/route.ts
Normal file
12
app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
18
app/api/auth/verify/route.ts
Normal file
18
app/api/auth/verify/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
22
app/api/test-verify/route.ts
Normal file
22
app/api/test-verify/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
32
app/api/vms/[id]/action/route.ts
Normal file
32
app/api/vms/[id]/action/route.ts
Normal 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
49
app/api/vms/[id]/route.ts
Normal 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
18
app/api/vms/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
43
app/globals.css
Normal file
43
app/globals.css
Normal 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
29
app/layout.tsx
Normal 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
151
app/login/page.tsx
Normal 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
21
app/page.tsx
Normal 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 />;
|
||||
}
|
||||
36
app/terminal/[vmid]/page.tsx
Normal file
36
app/terminal/[vmid]/page.tsx
Normal 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
23
app/test-page/page.tsx
Normal 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
252
app/vm/[id]/page.tsx
Normal 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
102
components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/LogoutButton.tsx
Normal file
31
components/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
components/StatusBadge.tsx
Normal file
27
components/StatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
components/TerminalClient.tsx
Normal file
219
components/TerminalClient.tsx
Normal 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
68
components/UserMenu.tsx
Normal 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
67
components/VMCard.tsx
Normal 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
46
components/ui/Button.tsx
Normal 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
22
components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
components/ui/Skeleton.tsx
Normal file
12
components/ui/Skeleton.tsx
Normal 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
35
ecosystem.config.js
Normal 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
18
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;
|
||||
68
lib/auth.ts
Normal file
68
lib/auth.ts
Normal 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
126
lib/ssh.ts
Normal 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
6
lib/utils.ts
Normal 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
48
middleware.ts.backup
Normal 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
7
next.config.ts
Normal 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
7872
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal 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
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
155
server.mjs
Normal 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
8
start.sh
Executable 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
64
tailwind.config.ts
Normal 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
30
test-auth.js
Normal 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
34
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": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user