mirror of
https://github.com/crocofied/CoreControl.git
synced 2025-12-29 16:14:43 +00:00
35
app/api/applications/add/route.ts
Normal file
35
app/api/applications/add/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
serverId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
publicURL: string;
|
||||
localURL: string;
|
||||
uptimecheckUrl: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { serverId, name, description, icon, publicURL, localURL, uptimecheckUrl } = body;
|
||||
|
||||
const application = await prisma.application.create({
|
||||
data: {
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
publicURL,
|
||||
localURL,
|
||||
uptimecheckUrl
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", application });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/api/applications/delete/route.ts
Normal file
25
app/api/applications/delete/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.application.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
await prisma.uptime_history.deleteMany({
|
||||
where: { applicationId: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
app/api/applications/edit/route.ts
Normal file
42
app/api/applications/edit/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface EditRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
serverId: number;
|
||||
icon: string;
|
||||
publicURL: string;
|
||||
localURL: string;
|
||||
uptimecheckUrl: string;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: EditRequest = await request.json();
|
||||
const { id, name, description, serverId, icon, publicURL, localURL, uptimecheckUrl } = body;
|
||||
|
||||
const existingApp = await prisma.application.findUnique({ where: { id } });
|
||||
if (!existingApp) {
|
||||
return NextResponse.json({ error: "Server not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updatedApplication = await prisma.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
publicURL,
|
||||
localURL,
|
||||
uptimecheckUrl
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Application updated", application: updatedApplication });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
app/api/applications/get/route.ts
Normal file
50
app/api/applications/get/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface PostRequest {
|
||||
page?: number;
|
||||
ITEMS_PER_PAGE?: number;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: PostRequest = await request.json();
|
||||
const page = Math.max(1, body.page || 1);
|
||||
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 10;
|
||||
|
||||
const [applications, totalCount, servers_all] = await Promise.all([
|
||||
prisma.application.findMany({
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
take: ITEMS_PER_PAGE,
|
||||
orderBy: { name: "asc" }
|
||||
}),
|
||||
prisma.application.count(),
|
||||
prisma.server.findMany()
|
||||
]);
|
||||
|
||||
const serverIds = applications
|
||||
.map((app: { serverId: number | null }) => app.serverId)
|
||||
.filter((id:any): id is number => id !== null);
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { id: { in: serverIds } }
|
||||
});
|
||||
|
||||
const applicationsWithServers = applications.map((app: any) => ({
|
||||
...app,
|
||||
server: servers.find((s: any) => s.id === app.serverId)?.name || "No server"
|
||||
}));
|
||||
|
||||
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return NextResponse.json({
|
||||
applications: applicationsWithServers,
|
||||
servers: servers_all,
|
||||
maxPage,
|
||||
totalItems: totalCount
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
app/api/applications/search/route.ts
Normal file
48
app/api/applications/search/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
interface SearchRequest {
|
||||
searchterm: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SearchRequest = await request.json();
|
||||
const { searchterm } = body;
|
||||
|
||||
const applications = await prisma.application.findMany({});
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(applications, fuseOptions);
|
||||
|
||||
const searchResults = fuse.search(searchterm);
|
||||
|
||||
const searchedApps = searchResults.map(({ item }) => item);
|
||||
|
||||
// Get server IDs from the search results
|
||||
const serverIds = searchedApps
|
||||
.map(app => app.serverId)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
// Fetch server data for these applications
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { id: { in: serverIds } }
|
||||
});
|
||||
|
||||
// Add server name to each application
|
||||
const results = searchedApps.map(app => ({
|
||||
...app,
|
||||
server: servers.find(s => s.id === app.serverId)?.name || "No server"
|
||||
}));
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
175
app/api/applications/uptime/route.ts
Normal file
175
app/api/applications/uptime/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface RequestBody {
|
||||
timespan?: number;
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
|
||||
const getTimeRange = (timespan: number) => {
|
||||
const now = new Date();
|
||||
switch (timespan) {
|
||||
case 1:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
interval: 'minute'
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
interval: 'hour'
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
interval: 'day'
|
||||
};
|
||||
case 4:
|
||||
return {
|
||||
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
interval: 'day'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
interval: 'minute'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const generateIntervals = (timespan: number) => {
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
|
||||
switch (timespan) {
|
||||
case 1: // 1 hour - 60 one-minute intervals
|
||||
return Array.from({ length: 60 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setMinutes(d.getMinutes() - i);
|
||||
d.setSeconds(0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 2: // 1 day - 24 one-hour intervals
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setHours(d.getHours() - i);
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 3: // 7 days
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 4: // 30 days
|
||||
return Array.from({ length: 30 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getIntervalKey = (date: Date, timespan: number) => {
|
||||
const d = new Date(date);
|
||||
switch (timespan) {
|
||||
case 1: // 1 hour - minute intervals
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString();
|
||||
case 2: // 1 day - hour intervals
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d.toISOString();
|
||||
case 3: // 7 days - day intervals
|
||||
case 4: // 30 days - day intervals
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
default:
|
||||
return d.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { timespan = 1, page = 1, itemsPerPage = 5 }: RequestBody = await request.json();
|
||||
const skip = (page - 1) * itemsPerPage;
|
||||
|
||||
// Get paginated and sorted applications
|
||||
const [applications, totalCount] = await Promise.all([
|
||||
prisma.application.findMany({
|
||||
skip,
|
||||
take: itemsPerPage,
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.application.count()
|
||||
]);
|
||||
|
||||
const applicationIds = applications.map(app => app.id);
|
||||
|
||||
// Get time range and intervals
|
||||
const { start } = getTimeRange(timespan);
|
||||
const intervals = generateIntervals(timespan);
|
||||
|
||||
// Get uptime history for the filtered applications
|
||||
const uptimeHistory = await prisma.uptime_history.findMany({
|
||||
where: {
|
||||
applicationId: { in: applicationIds },
|
||||
createdAt: { gte: start }
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
// Process data for each application
|
||||
const result = applications.map(app => {
|
||||
const appChecks = uptimeHistory.filter(check => check.applicationId === app.id);
|
||||
const checksMap = new Map<string, { failed: number; total: number }>();
|
||||
|
||||
for (const check of appChecks) {
|
||||
const intervalKey = getIntervalKey(check.createdAt, timespan);
|
||||
const current = checksMap.get(intervalKey) || { failed: 0, total: 0 };
|
||||
current.total++;
|
||||
if (!check.online) current.failed++;
|
||||
checksMap.set(intervalKey, current);
|
||||
}
|
||||
|
||||
const uptimeSummary = intervals.map(interval => {
|
||||
const intervalKey = getIntervalKey(interval, timespan);
|
||||
const stats = checksMap.get(intervalKey);
|
||||
|
||||
return {
|
||||
timestamp: intervalKey,
|
||||
missing: !stats,
|
||||
online: stats ? (stats.failed / stats.total) <= 0.5 : null
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
appName: app.name,
|
||||
appId: app.id,
|
||||
uptimeSummary
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: result,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / itemsPerPage),
|
||||
totalItems: totalCount
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
56
app/api/auth/edit_email/route.ts
Normal file
56
app/api/auth/edit_email/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface EditEmailRequest {
|
||||
newEmail: string;
|
||||
jwtToken: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: EditEmailRequest = await request.json();
|
||||
const { newEmail, jwtToken } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
|
||||
if (!decoded.account_secret) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the user by account id
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.account_secret },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
// Check if the new email is already in use
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Email already in use' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update the user's email
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email: newEmail },
|
||||
});
|
||||
|
||||
|
||||
return NextResponse.json({ message: 'Email updated successfully' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
55
app/api/auth/edit_password/route.ts
Normal file
55
app/api/auth/edit_password/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
interface EditEmailRequest {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
jwtToken: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: EditEmailRequest = await request.json();
|
||||
const { oldPassword, newPassword, jwtToken } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
|
||||
if (!decoded.account_secret) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the user by account id
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.account_secret },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if the old password is correct
|
||||
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password);
|
||||
if (!isOldPasswordValid) {
|
||||
return NextResponse.json({ error: 'Old password is incorrect' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
|
||||
// Update the user's password
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedNewPassword },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Password updated successfully' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
65
app/api/auth/login/route.ts
Normal file
65
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: LoginRequest = await request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
let accountId: string = '';
|
||||
// Check if there are any entries in user
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
if(username=== "admin@example.com" && password === "admin") {
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
// Create the first user with hashed password
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the account id
|
||||
accountId = user.id;
|
||||
} else {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
} else {
|
||||
// Get the user by username
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: username },
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
// Check if the password is correct
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
// Get the account id
|
||||
accountId = user.id;
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
const token = jwt.sign({ account_secret: accountId }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
||||
|
||||
return NextResponse.json({ token });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
app/api/auth/validate/route.ts
Normal file
43
app/api/auth/validate/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface ValidateRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: ValidateRequest = await request.json();
|
||||
const { token } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
// Get the account id
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {},
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
|
||||
|
||||
if(!decoded.account_secret) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
if(decoded.account_secret !== user.id) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({ message: 'Valid token' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
app/api/dashboard/get/route.ts
Normal file
35
app/api/dashboard/get/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const serverCountNoVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: 0
|
||||
}
|
||||
});
|
||||
|
||||
const serverCountOnlyVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: {
|
||||
not: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const applicationCount = await prisma.application.count();
|
||||
|
||||
const onlineApplicationsCount = await prisma.application.count({
|
||||
where: { online: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
serverCountNoVMs,
|
||||
serverCountOnlyVMs,
|
||||
applicationCount,
|
||||
onlineApplicationsCount
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
409
app/api/flowchart/route.ts
Normal file
409
app/api/flowchart/route.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
label: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
position: { x: number; y: number };
|
||||
style: React.CSSProperties;
|
||||
draggable?: boolean;
|
||||
selectable?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
style: {
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
host: boolean;
|
||||
hostServer: number | null;
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: number;
|
||||
name: string;
|
||||
localURL: string;
|
||||
serverId: number;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 60;
|
||||
const APP_NODE_WIDTH = 160;
|
||||
const APP_NODE_HEIGHT = 40;
|
||||
const HORIZONTAL_SPACING = 700;
|
||||
const VERTICAL_SPACING = 80;
|
||||
const START_Y = 120;
|
||||
const ROOT_NODE_WIDTH = 300;
|
||||
const CONTAINER_PADDING = 40;
|
||||
const COLUMN_SPACING = 220;
|
||||
const VM_APP_SPACING = 220;
|
||||
const MIN_VM_SPACING = 10;
|
||||
const APP_ROW_SPACING = 15;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [servers, applications] = await Promise.all([
|
||||
prisma.server.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
}) as Promise<Server[]>,
|
||||
prisma.application.findMany({
|
||||
orderBy: { serverId: "asc" },
|
||||
}) as Promise<Application[]>,
|
||||
]);
|
||||
|
||||
// Level 2: Physical Servers
|
||||
const serverNodes: Node[] = servers
|
||||
.filter(server => !server.hostServer)
|
||||
.map((server, index, filteredServers) => {
|
||||
const xPos =
|
||||
index * HORIZONTAL_SPACING -
|
||||
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
|
||||
|
||||
return {
|
||||
id: `server-${server.id}`,
|
||||
type: "server",
|
||||
data: {
|
||||
label: `${server.name}\n${server.ip}`,
|
||||
...server,
|
||||
},
|
||||
position: { x: xPos, y: START_Y },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "4px",
|
||||
padding: "8px",
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "0.9rem",
|
||||
lineHeight: "1.2",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Level 3: Services and VMs
|
||||
const serviceNodes: Node[] = [];
|
||||
const vmNodes: Node[] = [];
|
||||
|
||||
servers.forEach((server) => {
|
||||
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
|
||||
if (serverNode) {
|
||||
const serverX = serverNode.position.x;
|
||||
|
||||
// Services (left column)
|
||||
applications
|
||||
.filter(app => app.serverId === server.id)
|
||||
.forEach((app, appIndex) => {
|
||||
serviceNodes.push({
|
||||
id: `service-${app.id}`,
|
||||
type: "service",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: serverX - COLUMN_SPACING,
|
||||
y: START_Y + NODE_HEIGHT + VERTICAL_SPACING + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f0f9ff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #60a5fa",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// VMs (middle column) mit dynamischem Abstand
|
||||
const hostVMs = servers.filter(vm => vm.hostServer === server.id);
|
||||
let currentY = START_Y + NODE_HEIGHT + VERTICAL_SPACING;
|
||||
|
||||
hostVMs.forEach(vm => {
|
||||
const appCount = applications.filter(app => app.serverId === vm.id).length;
|
||||
|
||||
vmNodes.push({
|
||||
id: `vm-${vm.id}`,
|
||||
type: "vm",
|
||||
data: {
|
||||
label: `${vm.name}\n${vm.ip}`,
|
||||
...vm,
|
||||
},
|
||||
position: {
|
||||
x: serverX,
|
||||
y: currentY,
|
||||
},
|
||||
style: {
|
||||
background: "#fef2f2",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #fecaca",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamischer Abstand basierend auf Anzahl Apps
|
||||
const requiredSpace = appCount > 0
|
||||
? (appCount * (APP_NODE_HEIGHT + APP_ROW_SPACING))
|
||||
: 0;
|
||||
|
||||
currentY += Math.max(
|
||||
requiredSpace + MIN_VM_SPACING,
|
||||
MIN_VM_SPACING + APP_NODE_HEIGHT
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Level 4: VM Applications (right column)
|
||||
const vmAppNodes: Node[] = [];
|
||||
vmNodes.forEach((vm) => {
|
||||
const vmX = vm.position.x;
|
||||
applications
|
||||
.filter(app => app.serverId === vm.data.id)
|
||||
.forEach((app, appIndex) => {
|
||||
vmAppNodes.push({
|
||||
id: `vm-app-${app.id}`,
|
||||
type: "application",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: vmX + VM_APP_SPACING,
|
||||
y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f5f5f5",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate dimensions for root node positioning
|
||||
const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
tempNodes.forEach((node) => {
|
||||
const width = parseInt(node.style.width?.toString() || "0", 10);
|
||||
const height = parseInt(node.style.height?.toString() || "0", 10);
|
||||
|
||||
minX = Math.min(minX, node.position.x);
|
||||
maxX = Math.max(maxX, node.position.x + width);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxY = Math.max(maxY, node.position.y + height);
|
||||
});
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const rootX = centerX - ROOT_NODE_WIDTH / 2;
|
||||
|
||||
// Level 1: Root Node (centered at top)
|
||||
const rootNode: Node = {
|
||||
id: "root",
|
||||
type: "infrastructure",
|
||||
data: { label: "My Infrastructure" },
|
||||
position: { x: rootX, y: 0 },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
width: ROOT_NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "1.2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
};
|
||||
|
||||
// Update dimensions with root node
|
||||
const allNodes = [rootNode, ...tempNodes];
|
||||
let newMinX = Math.min(minX, rootNode.position.x);
|
||||
let newMaxX = Math.max(maxX, rootNode.position.x + ROOT_NODE_WIDTH);
|
||||
let newMinY = Math.min(minY, rootNode.position.y);
|
||||
let newMaxY = Math.max(maxY, rootNode.position.y + NODE_HEIGHT);
|
||||
|
||||
// Container Node
|
||||
const containerNode: Node = {
|
||||
id: 'container',
|
||||
type: 'container',
|
||||
data: { label: '' },
|
||||
position: {
|
||||
x: newMinX - CONTAINER_PADDING,
|
||||
y: newMinY - CONTAINER_PADDING
|
||||
},
|
||||
style: {
|
||||
width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
|
||||
height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
|
||||
background: 'transparent',
|
||||
border: '2px dashed #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
zIndex: 0,
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
zIndex: -1,
|
||||
};
|
||||
|
||||
// Connections with hierarchical chaining
|
||||
const connections: Edge[] = [];
|
||||
|
||||
// Root to Servers
|
||||
serverNodes.forEach((server) => {
|
||||
connections.push({
|
||||
id: `conn-root-${server.id}`,
|
||||
source: "root",
|
||||
target: server.id,
|
||||
type: "straight",
|
||||
style: {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Services chaining
|
||||
const servicesByServer = new Map<number, Node[]>();
|
||||
serviceNodes.forEach(service => {
|
||||
const serverId = service.data.serverId;
|
||||
if (!servicesByServer.has(serverId)) servicesByServer.set(serverId, []);
|
||||
servicesByServer.get(serverId)!.push(service);
|
||||
});
|
||||
servicesByServer.forEach((services, serverId) => {
|
||||
services.sort((a, b) => a.position.y - b.position.y);
|
||||
services.forEach((service, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}`,
|
||||
source: `server-${serverId}`,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevService = services[index - 1];
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}-${prevService.id}`,
|
||||
source: prevService.id,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VMs chaining
|
||||
const vmsByHost = new Map<number, Node[]>();
|
||||
vmNodes.forEach(vm => {
|
||||
const hostId = vm.data.hostServer;
|
||||
if (!vmsByHost.has(hostId)) vmsByHost.set(hostId, []);
|
||||
vmsByHost.get(hostId)!.push(vm);
|
||||
});
|
||||
vmsByHost.forEach((vms, hostId) => {
|
||||
vms.sort((a, b) => a.position.y - b.position.y);
|
||||
vms.forEach((vm, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}`,
|
||||
source: `server-${hostId}`,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevVm = vms[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}-${prevVm.id}`,
|
||||
source: prevVm.id,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VM Applications chaining
|
||||
const appsByVM = new Map<number, Node[]>();
|
||||
vmAppNodes.forEach(app => {
|
||||
const vmId = app.data.serverId;
|
||||
if (!appsByVM.has(vmId)) appsByVM.set(vmId, []);
|
||||
appsByVM.get(vmId)!.push(app);
|
||||
});
|
||||
appsByVM.forEach((apps, vmId) => {
|
||||
apps.sort((a, b) => a.position.y - b.position.y);
|
||||
apps.forEach((app, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}`,
|
||||
source: `vm-${vmId}`,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevApp = apps[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}-${prevApp.id}`,
|
||||
source: prevApp.id,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
nodes: [containerNode, ...allNodes],
|
||||
edges: connections,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Error fetching flowchart: ${errorMessage}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/api/notifications/add/route.ts
Normal file
61
app/api/notifications/add/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
type: string;
|
||||
name: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTo?: string;
|
||||
telegramToken?: string;
|
||||
telegramChatId?: string;
|
||||
discordWebhook?: string;
|
||||
gotifyUrl?: string;
|
||||
gotifyToken?: string;
|
||||
ntfyUrl?: string;
|
||||
ntfyToken?: string;
|
||||
pushoverUrl?: string;
|
||||
pushoverToken?: string;
|
||||
pushoverUser?: string;
|
||||
echobellURL?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { type, name, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken, pushoverUrl, pushoverToken, pushoverUser, echobellURL } = body;
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
type: type,
|
||||
name: name,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpUser: smtpUsername,
|
||||
smtpPass: smtpPassword,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpTo: smtpTo,
|
||||
telegramChatId: telegramChatId,
|
||||
telegramToken: telegramToken,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
pushoverUrl: pushoverUrl,
|
||||
pushoverToken: pushoverToken,
|
||||
pushoverUser: pushoverUser,
|
||||
echobellURL: echobellURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", notification });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/notifications/delete/route.ts
Normal file
21
app/api/notifications/delete/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.notification.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
app/api/notifications/get/route.ts
Normal file
16
app/api/notifications/get/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
const notifications = await prisma.notification.findMany();
|
||||
|
||||
return NextResponse.json({
|
||||
notifications
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/notifications/test/route.ts
Normal file
23
app/api/notifications/test/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
notificationId: number;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { notificationId } = body;
|
||||
|
||||
const notification = await prisma.test_notification.create({
|
||||
data: {
|
||||
notificationId: notificationId,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", notification });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
app/api/servers/add/route.ts
Normal file
48
app/api/servers/add/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
ram: string;
|
||||
disk: string;
|
||||
monitoring: boolean;
|
||||
monitoringURL: string;
|
||||
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
|
||||
|
||||
const server = await prisma.server.create({
|
||||
data: {
|
||||
host,
|
||||
hostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
disk,
|
||||
monitoring,
|
||||
monitoringURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", server });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
app/api/servers/delete/route.ts
Normal file
35
app/api/servers/delete/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if there are any applications associated with the server
|
||||
const applications = await prisma.application.findMany({
|
||||
where: { serverId: id }
|
||||
});
|
||||
if (applications.length > 0) {
|
||||
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete all server history records for this server
|
||||
await prisma.server_history.deleteMany({
|
||||
where: { serverId: id }
|
||||
});
|
||||
|
||||
// Delete the server
|
||||
await prisma.server.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
61
app/api/servers/edit/route.ts
Normal file
61
app/api/servers/edit/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface EditRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
ram: string;
|
||||
disk: string;
|
||||
monitoring: boolean;
|
||||
monitoringURL: string;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: EditRequest = await request.json();
|
||||
const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
|
||||
|
||||
const existingServer = await prisma.server.findUnique({ where: { id } });
|
||||
if (!existingServer) {
|
||||
return NextResponse.json({ error: "Server not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let newHostServer = hostServer;
|
||||
if (hostServer === null) {
|
||||
newHostServer = 0;
|
||||
} else {
|
||||
newHostServer = hostServer;
|
||||
}
|
||||
|
||||
const updatedServer = await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
host,
|
||||
hostServer: newHostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
disk,
|
||||
monitoring,
|
||||
monitoringURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Server updated", server: updatedServer });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
270
app/api/servers/get/route.ts
Normal file
270
app/api/servers/get/route.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
interface GetRequest {
|
||||
page?: number;
|
||||
ITEMS_PER_PAGE?: number;
|
||||
timeRange?: '1h' | '1d' | '7d' | '30d';
|
||||
serverId?: number;
|
||||
}
|
||||
|
||||
const getTimeRange = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case '1d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 15 // 15 minute intervals
|
||||
};
|
||||
case '7d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 60 // 1 hour intervals
|
||||
};
|
||||
case '30d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 240 // 4 hour intervals
|
||||
};
|
||||
case '1h':
|
||||
default:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 1 // 1 minute intervals
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getIntervals = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
|
||||
const { start, end, intervalMinutes } = getTimeRange(timeRange);
|
||||
|
||||
let intervalCount: number;
|
||||
switch (timeRange) {
|
||||
case '1d':
|
||||
intervalCount = 96; // 24 hours * 4 (15-minute intervals)
|
||||
break;
|
||||
case '7d':
|
||||
intervalCount = 168; // 7 days * 24 hours
|
||||
break;
|
||||
case '30d':
|
||||
intervalCount = 180; // 30 days * 6 (4-hour intervals)
|
||||
break;
|
||||
case '1h':
|
||||
default:
|
||||
intervalCount = 60;
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate the total time span in minutes
|
||||
const totalMinutes = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
|
||||
|
||||
// Create equally spaced intervals
|
||||
return Array.from({ length: intervalCount }, (_, i) => {
|
||||
const minutesFromEnd = Math.floor(i * (totalMinutes / (intervalCount - 1)));
|
||||
const d = new Date(end.getTime() - minutesFromEnd * 60 * 1000);
|
||||
return d;
|
||||
}).reverse(); // Return in chronological order
|
||||
};
|
||||
|
||||
const parseUsageValue = (value: string | null): number => {
|
||||
if (!value) return 0;
|
||||
return Math.round(parseFloat(value.replace('%', '')) * 100) / 100;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: GetRequest = await request.json();
|
||||
const page = Math.max(1, body.page || 1);
|
||||
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
|
||||
const timeRange = body.timeRange || '1h';
|
||||
const serverId = body.serverId;
|
||||
|
||||
// If serverId is provided, only fetch that specific server
|
||||
const hostsQuery = serverId
|
||||
? { id: serverId }
|
||||
: { hostServer: 0 };
|
||||
|
||||
let hosts;
|
||||
if (!serverId) {
|
||||
hosts = await prisma.server.findMany({
|
||||
where: hostsQuery,
|
||||
orderBy: { name: 'asc' as Prisma.SortOrder },
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
take: ITEMS_PER_PAGE,
|
||||
});
|
||||
} else {
|
||||
hosts = await prisma.server.findMany({
|
||||
where: hostsQuery,
|
||||
orderBy: { name: 'asc' as Prisma.SortOrder },
|
||||
});
|
||||
}
|
||||
|
||||
const { start } = getTimeRange(timeRange);
|
||||
const intervals = getIntervals(timeRange);
|
||||
|
||||
const hostsWithVms = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const vms = await prisma.server.findMany({
|
||||
where: { hostServer: host.id },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
// Get server history for the host
|
||||
const serverHistory = await prisma.server_history.findMany({
|
||||
where: {
|
||||
serverId: host.id,
|
||||
createdAt: {
|
||||
gte: start
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
// Process history data into intervals
|
||||
const historyMap = new Map<string, {
|
||||
cpu: number[],
|
||||
ram: number[],
|
||||
disk: number[],
|
||||
gpu: number[],
|
||||
temp: number[],
|
||||
online: boolean[]
|
||||
}>();
|
||||
|
||||
// Initialize intervals
|
||||
intervals.forEach(date => {
|
||||
const key = date.toISOString();
|
||||
historyMap.set(key, {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
disk: [],
|
||||
gpu: [],
|
||||
temp: [],
|
||||
online: []
|
||||
});
|
||||
});
|
||||
|
||||
// Group data by interval
|
||||
serverHistory.forEach(record => {
|
||||
const recordDate = new Date(record.createdAt);
|
||||
let nearestInterval: Date = intervals[0];
|
||||
let minDiff = Infinity;
|
||||
|
||||
// Find the nearest interval for this record
|
||||
intervals.forEach(intervalDate => {
|
||||
const diff = Math.abs(recordDate.getTime() - intervalDate.getTime());
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nearestInterval = intervalDate;
|
||||
}
|
||||
});
|
||||
|
||||
const key = nearestInterval.toISOString();
|
||||
const interval = historyMap.get(key);
|
||||
if (interval) {
|
||||
interval.cpu.push(parseUsageValue(record.cpuUsage));
|
||||
interval.ram.push(parseUsageValue(record.ramUsage));
|
||||
interval.disk.push(parseUsageValue(record.diskUsage));
|
||||
interval.gpu.push(parseUsageValue(record.gpuUsage));
|
||||
interval.temp.push(parseUsageValue(record.temp));
|
||||
interval.online.push(record.online);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate averages for each interval
|
||||
const historyData = intervals.map(date => {
|
||||
const key = date.toISOString();
|
||||
const data = historyMap.get(key) || {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
disk: [],
|
||||
gpu: [],
|
||||
temp: [],
|
||||
online: []
|
||||
};
|
||||
|
||||
const average = (arr: number[]) =>
|
||||
arr.length ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : null;
|
||||
|
||||
return {
|
||||
timestamp: key,
|
||||
cpu: average(data.cpu),
|
||||
ram: average(data.ram),
|
||||
disk: average(data.disk),
|
||||
gpu: average(data.gpu),
|
||||
temp: average(data.temp),
|
||||
online: data.online.length ?
|
||||
data.online.filter(Boolean).length / data.online.length >= 0.5
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
// Add isVM flag to VMs
|
||||
const vmsWithFlag = vms.map(vm => ({
|
||||
...vm,
|
||||
isVM: true,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array for VMs
|
||||
}));
|
||||
|
||||
return {
|
||||
...host,
|
||||
isVM: false,
|
||||
hostedVMs: vmsWithFlag,
|
||||
history: {
|
||||
labels: intervals.map(d => d.toISOString()),
|
||||
datasets: {
|
||||
cpu: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.cpu || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
ram: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.ram || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
disk: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.disk || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
gpu: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.gpu || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
temp: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.temp || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
online: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.online || [];
|
||||
return data.length ? data.filter(Boolean).length / data.length >= 0.5 : null;
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Only calculate maxPage when not requesting a specific server
|
||||
let maxPage = 1;
|
||||
let totalHosts = 0;
|
||||
if (!serverId) {
|
||||
totalHosts = await prisma.server.count({
|
||||
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
|
||||
});
|
||||
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
servers: hostsWithVms,
|
||||
maxPage,
|
||||
totalItems: totalHosts
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/servers/hosts/route.ts
Normal file
21
app/api/servers/hosts/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { host: true },
|
||||
});
|
||||
|
||||
// Add required properties to ensure consistency
|
||||
const serversWithProps = servers.map(server => ({
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array
|
||||
}));
|
||||
|
||||
return NextResponse.json({ servers: serversWithProps });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
app/api/servers/monitoring/route.ts
Normal file
44
app/api/servers/monitoring/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
online: true,
|
||||
cpuUsage: true,
|
||||
ramUsage: true,
|
||||
diskUsage: true,
|
||||
gpuUsage: true,
|
||||
temp: true,
|
||||
uptime: true
|
||||
}
|
||||
});
|
||||
|
||||
const monitoringData = servers.map((server: {
|
||||
id: number;
|
||||
online: boolean;
|
||||
cpuUsage: string | null;
|
||||
ramUsage: string | null;
|
||||
diskUsage: string | null;
|
||||
gpuUsage: string | null;
|
||||
temp: string | null;
|
||||
uptime: string | null;
|
||||
}) => ({
|
||||
id: server.id,
|
||||
online: server.online,
|
||||
cpuUsage: server.cpuUsage ? parseFloat(server.cpuUsage) : 0,
|
||||
ramUsage: server.ramUsage ? parseFloat(server.ramUsage) : 0,
|
||||
diskUsage: server.diskUsage ? parseFloat(server.diskUsage) : 0,
|
||||
gpuUsage: server.gpuUsage ? parseFloat(server.gpuUsage) : 0,
|
||||
temp: server.temp ? parseFloat(server.temp) : 0,
|
||||
uptime: server.uptime || ""
|
||||
}));
|
||||
|
||||
return NextResponse.json(monitoringData)
|
||||
} catch (error) {
|
||||
return new NextResponse("Internal Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
68
app/api/servers/search/route.ts
Normal file
68
app/api/servers/search/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
interface SearchRequest {
|
||||
searchterm: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SearchRequest = await request.json();
|
||||
const { searchterm } = body;
|
||||
|
||||
// Fetch all servers
|
||||
const servers = await prisma.server.findMany({});
|
||||
|
||||
// Create a map of host servers with their hosted VMs
|
||||
const serverMap = new Map();
|
||||
servers.forEach(server => {
|
||||
if (server.host) {
|
||||
serverMap.set(server.id, {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add VMs to their host servers and mark them as VMs
|
||||
const serversWithType = servers.map(server => {
|
||||
// If not a host and has a hostServer, it's a VM
|
||||
if (!server.host && server.hostServer) {
|
||||
const hostServer = serverMap.get(server.hostServer);
|
||||
if (hostServer) {
|
||||
hostServer.hostedVMs.push({
|
||||
...server,
|
||||
isVM: true
|
||||
});
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: serverMap.get(server.id)?.hostedVMs || []
|
||||
};
|
||||
});
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(serversWithType, fuseOptions);
|
||||
|
||||
const searchResults = fuse.search(searchterm);
|
||||
|
||||
const results = searchResults.map(({ item }) => item);
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/api/settings/get_notification_text/route.ts
Normal file
25
app/api/settings/get_notification_text/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check if there are any settings entries
|
||||
const existingSettings = await prisma.settings.findFirst();
|
||||
if (!existingSettings) {
|
||||
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
|
||||
}
|
||||
|
||||
// If settings entry exists, fetch it
|
||||
const settings = await prisma.settings.findFirst({
|
||||
where: { id: existingSettings.id },
|
||||
});
|
||||
if (!settings) {
|
||||
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
|
||||
}
|
||||
// Return the settings entry
|
||||
return NextResponse.json({ "notification_text_application": settings.notification_text_application, "notification_text_server": settings.notification_text_server });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
36
app/api/settings/notification_text/route.ts
Normal file
36
app/api/settings/notification_text/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
text_application: string;
|
||||
text_server: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { text_application, text_server } = body;
|
||||
|
||||
// Check if there is already a settings entry
|
||||
const existingSettings = await prisma.settings.findFirst();
|
||||
if (existingSettings) {
|
||||
// Update the existing settings entry
|
||||
const updatedSettings = await prisma.settings.update({
|
||||
where: { id: existingSettings.id },
|
||||
data: { notification_text_application: text_application, notification_text_server: text_server },
|
||||
});
|
||||
return NextResponse.json({ message: "Success", updatedSettings });
|
||||
}
|
||||
// If no settings entry exists, create a new one
|
||||
const settings = await prisma.settings.create({
|
||||
data: {
|
||||
notification_text_application: text_application,
|
||||
notification_text_server: text_server,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", settings });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
231
app/dashboard/Dashboard.tsx
Normal file
231
app/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import axios from "axios"
|
||||
import Link from "next/link"
|
||||
import { Activity, Layers, Network, Server } from "lucide-react"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface StatsResponse {
|
||||
serverCountNoVMs: number
|
||||
serverCountOnlyVMs: number
|
||||
applicationCount: number
|
||||
onlineApplicationsCount: number
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('Dashboard')
|
||||
const [serverCountNoVMs, setServerCountNoVMs] = useState<number>(0)
|
||||
const [serverCountOnlyVMs, setServerCountOnlyVMs] = useState<number>(0)
|
||||
const [applicationCount, setApplicationCount] = useState<number>(0)
|
||||
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
|
||||
|
||||
const getStats = async () => {
|
||||
try {
|
||||
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
|
||||
setServerCountNoVMs(response.data.serverCountNoVMs)
|
||||
setServerCountOnlyVMs(response.data.serverCountOnlyVMs)
|
||||
setApplicationCount(response.data.applicationCount)
|
||||
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
|
||||
} catch (error: any) {
|
||||
console.log("Axios error:", error.response?.data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getStats()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage>/</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Title')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-6">{t('Title')}</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
|
||||
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-lg transition-all hover:shadow-xl hover:border-t-rose-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Servers.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Servers.Description')}</CardDescription>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Physical Servers */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-rose-100 p-2 rounded-full">
|
||||
<Server className="h-6 w-6 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountNoVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">{t('Servers.PhysicalServers')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Virtual Machines */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-violet-100 p-2 rounded-full">
|
||||
<Network className="h-6 w-6 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountOnlyVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">{t('Servers.VirtualServers')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/servers" className="flex items-center justify-between">
|
||||
<span>{t('Servers.ManageServers')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-lg transition-all hover:shadow-xl hover:border-t-amber-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Applications.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Applications.Description')}</CardDescription>
|
||||
</div>
|
||||
<Layers className="h-8 w-8 text-amber-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="text-4xl font-bold">{applicationCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Applications.OnlineApplications')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/applications" className="flex items-center justify-between">
|
||||
<span>{t('Applications.ViewAllApplications')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-lg transition-all hover:shadow-xl hover:border-t-emerald-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Uptime.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Uptime.Description')}</CardDescription>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-emerald-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-4xl font-bold flex items-center justify-between">
|
||||
<span>
|
||||
{onlineApplicationsCount}/{applicationCount}
|
||||
</span>
|
||||
<div className="flex items-center bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-lg font-semibold">
|
||||
{applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-3">
|
||||
<div
|
||||
className="bg-emerald-500 h-2.5 rounded-full"
|
||||
style={{
|
||||
width: `${applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Uptime.OnlineApplications')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/uptime" className="flex items-center justify-between">
|
||||
<span>{t('Uptime.ViewUptimeMetrics')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-lg transition-all hover:shadow-xl hover:border-t-sky-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Network.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Network.Description')}</CardDescription>
|
||||
</div>
|
||||
<Network className="h-8 w-8 text-sky-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="text-4xl font-bold">{serverCountNoVMs + serverCountOnlyVMs + applicationCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Network.ActiveConnections')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/network" className="flex items-center justify-between">
|
||||
<span>{t('Network.ViewNetworkDetails')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
1172
app/dashboard/applications/Applications.tsx
Normal file
1172
app/dashboard/applications/Applications.tsx
Normal file
File diff suppressed because it is too large
Load Diff
59
app/dashboard/applications/page.tsx
Normal file
59
app/dashboard/applications/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Applications from "./Applications"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Applications /> : null;
|
||||
}
|
||||
101
app/dashboard/network/Networks.tsx
Normal file
101
app/dashboard/network/Networks.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ReactFlow, Controls, Background, ConnectionLineType } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations();
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [edges, setEdges] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFlowData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/flowchart");
|
||||
const data = await response.json();
|
||||
|
||||
setNodes(data.nodes);
|
||||
setEdges(data.edges);
|
||||
} catch (error) {
|
||||
console.error("Error loading flowchart:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFlowData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex flex-col h-screen">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 dark:text-white" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 h-4 dark:bg-slate-700"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
/
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block dark:text-slate-500" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
{t('Network.Breadcrumb.MyInfrastructure')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block dark:text-slate-500" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
{t('Network.Breadcrumb.Network')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 pl-4 pr-4">
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className="dark:bg-black rounded-lg"
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
connectionLineType={ConnectionLineType.Straight}
|
||||
className="dark:[&_.react-flow__edge-path]:stroke-slate-500"
|
||||
>
|
||||
<Background
|
||||
color="#64748b"
|
||||
gap={40}
|
||||
className="dark:opacity-20"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
59
app/dashboard/network/page.tsx
Normal file
59
app/dashboard/network/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Networks from "./Networks"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Networks /> : null;
|
||||
}
|
||||
59
app/dashboard/page.tsx
Normal file
59
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Dashboard from "./Dashboard"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Dashboard /> : null;
|
||||
}
|
||||
2116
app/dashboard/servers/Servers.tsx
Normal file
2116
app/dashboard/servers/Servers.tsx
Normal file
File diff suppressed because it is too large
Load Diff
897
app/dashboard/servers/[server_id]/Server.tsx
Normal file
897
app/dashboard/servers/[server_id]/Server.tsx
Normal file
@@ -0,0 +1,897 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import Chart from 'chart.js/auto'
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Link, Cpu, MicroscopeIcon as Microchip, MemoryStick, HardDrive, MonitorIcon as MonitorCog, FileDigit, History } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { StatusIndicator } from "@/components/status-indicator"
|
||||
import { DynamicIcon } from "lucide-react/dynamic"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import NextLink from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface ServerHistory {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
cpu: (number | null)[];
|
||||
ram: (number | null)[];
|
||||
disk: (number | null)[];
|
||||
online: (boolean | null)[];
|
||||
gpu: (number | null)[];
|
||||
temp: (number | null)[];
|
||||
}
|
||||
}
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
host: boolean;
|
||||
hostServer: number | null;
|
||||
os?: string;
|
||||
ip?: string;
|
||||
url?: string;
|
||||
cpu?: string;
|
||||
gpu?: string;
|
||||
ram?: string;
|
||||
disk?: string;
|
||||
hostedVMs?: Server[];
|
||||
isVM?: boolean;
|
||||
monitoring?: boolean;
|
||||
monitoringURL?: string;
|
||||
online?: boolean;
|
||||
cpuUsage: number;
|
||||
ramUsage: number;
|
||||
diskUsage: number;
|
||||
gpuUsage: number;
|
||||
temp: number;
|
||||
history?: ServerHistory;
|
||||
port: number;
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
interface GetServersResponse {
|
||||
servers: Server[];
|
||||
maxPage: number;
|
||||
}
|
||||
|
||||
export default function ServerDetail() {
|
||||
const t = useTranslations()
|
||||
const params = useParams()
|
||||
const serverId = params.server_id as string
|
||||
const [server, setServer] = useState<Server | null>(null)
|
||||
const [timeRange, setTimeRange] = useState<'1h' | '1d' | '7d' | '30d'>('1h')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Chart references
|
||||
const cpuChartRef = { current: null as Chart | null }
|
||||
const ramChartRef = { current: null as Chart | null }
|
||||
const diskChartRef = { current: null as Chart | null }
|
||||
const gpuChartRef = { current: null as Chart | null }
|
||||
const tempChartRef = { current: null as Chart | null }
|
||||
|
||||
const fetchServerDetails = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.post<GetServersResponse>("/api/servers/get", {
|
||||
serverId: parseInt(serverId),
|
||||
timeRange: timeRange
|
||||
})
|
||||
|
||||
if (response.data.servers && response.data.servers.length > 0) {
|
||||
setServer(response.data.servers[0])
|
||||
}
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch server details:", error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerDetails()
|
||||
}, [serverId, timeRange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!server || !server.history) return;
|
||||
|
||||
// Clean up existing charts
|
||||
if (cpuChartRef.current) cpuChartRef.current.destroy();
|
||||
if (ramChartRef.current) ramChartRef.current.destroy();
|
||||
if (diskChartRef.current) diskChartRef.current.destroy();
|
||||
if (gpuChartRef.current) gpuChartRef.current.destroy();
|
||||
if (tempChartRef.current) tempChartRef.current.destroy();
|
||||
|
||||
// Wait for DOM to be ready
|
||||
const initTimer = setTimeout(() => {
|
||||
const history = server.history as ServerHistory;
|
||||
|
||||
// Format time labels based on the selected time range
|
||||
const timeLabels = history.labels.map((date: string) => {
|
||||
const d = new Date(date)
|
||||
if (timeRange === '1h') {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (timeRange === '1d') {
|
||||
// For 1 day, show hours and minutes
|
||||
return d.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} else if (timeRange === '7d') {
|
||||
// For 7 days, show day and time
|
||||
return d.toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
}) + ' ' + d.toLocaleTimeString([], {
|
||||
hour: '2-digit'
|
||||
})
|
||||
} else {
|
||||
// For 30 days
|
||||
return d.toLocaleDateString([], {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Create a time range title for the chart
|
||||
const getRangeTitle = () => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(history.labels[0])
|
||||
|
||||
if (timeRange === '1h') {
|
||||
return `Last Hour (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
|
||||
} else if (timeRange === '1d') {
|
||||
return `Last 24 Hours (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
|
||||
} else if (timeRange === '7d') {
|
||||
return `Last 7 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
|
||||
} else {
|
||||
return `Last 30 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
|
||||
}
|
||||
}
|
||||
|
||||
// Directly hardcode the y-axis maximum in each chart option
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
autoSkip: false,
|
||||
callback: function(value: any) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Usage %'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
},
|
||||
line: {
|
||||
tension: 0.4,
|
||||
spanGaps: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create charts with very explicit y-axis max values
|
||||
const cpuCanvas = document.getElementById(`cpu-chart`) as HTMLCanvasElement
|
||||
if (cpuCanvas) {
|
||||
cpuChartRef.current = new Chart(cpuCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.CPU') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.cpu,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.CPU') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ramCanvas = document.getElementById(`ram-chart`) as HTMLCanvasElement
|
||||
if (ramCanvas) {
|
||||
ramChartRef.current = new Chart(ramCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.RAM') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.ram,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.RAM') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const diskCanvas = document.getElementById(`disk-chart`) as HTMLCanvasElement
|
||||
if (diskCanvas) {
|
||||
diskChartRef.current = new Chart(diskCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.Disk') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.disk,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.Disk') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const gpuCanvas = document.getElementById(`gpu-chart`) as HTMLCanvasElement
|
||||
if (gpuCanvas) {
|
||||
gpuChartRef.current = new Chart(gpuCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.GPU') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.gpu,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.GPU') + ' ' + t('Common.Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tempCanvas = document.getElementById(`temp-chart`) as HTMLCanvasElement
|
||||
if (tempCanvas) {
|
||||
tempChartRef.current = new Chart(tempCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.Temperature') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.temp,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.Temperature') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return value + '°C';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimer);
|
||||
if (cpuChartRef.current) cpuChartRef.current.destroy();
|
||||
if (ramChartRef.current) ramChartRef.current.destroy();
|
||||
if (diskChartRef.current) diskChartRef.current.destroy();
|
||||
if (gpuChartRef.current) gpuChartRef.current.destroy();
|
||||
if (tempChartRef.current) tempChartRef.current.destroy();
|
||||
};
|
||||
}, [server, timeRange]);
|
||||
|
||||
// Function to refresh data
|
||||
const refreshData = () => {
|
||||
fetchServerDetails()
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage>/</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Servers.MyInfrastructure')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<NextLink href="/dashboard/servers" className="hover:underline">
|
||||
<BreadcrumbPage>{t('Servers.Title')}</BreadcrumbPage>
|
||||
</NextLink>
|
||||
</BreadcrumbItem>
|
||||
{server && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{server.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="inline-block" role="status" aria-label="loading">
|
||||
<svg
|
||||
className="w-6 h-6 stroke-white animate-spin "
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_9023_61563)">
|
||||
<path
|
||||
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
|
||||
stroke="stroke-current"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
className="my-path"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9023_61563">
|
||||
<rect width="24" height="24" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className="sr-only">{t('Common.Loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : server ? (
|
||||
<div className="space-y-6">
|
||||
{/* Server header card */}
|
||||
<Card>
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{server.icon && <DynamicIcon name={server.icon as any} size={32} />}
|
||||
<div>
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{server.os || t('Common.Server.OS')} • {server.isVM ? t('Server.VM') : t('Server.Physical')}
|
||||
{server.isVM && server.hostServer && (
|
||||
<> • {t('Server.HostedOn')} {server.hostedVMs?.[0]?.name}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{server.monitoring && (
|
||||
<div className="absolute top-0 right-4 flex flex-col items-end">
|
||||
<StatusIndicator isOnline={server.online} />
|
||||
{server.online && server.uptime && (
|
||||
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
|
||||
{t('Common.since', { date: server.uptime })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.Hardware')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.CPU')}:</div>
|
||||
<div>{server.cpu || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.GPU')}:</div>
|
||||
<div>{server.gpu || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.RAM')}:</div>
|
||||
<div>{server.ram || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Disk')}:</div>
|
||||
<div>{server.disk || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.Network')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.IP')}:</div>
|
||||
<div>{server.ip || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Server.ManagementURL')}:</div>
|
||||
<div>
|
||||
{server.url ? (
|
||||
<a href={server.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-500 hover:underline">
|
||||
{server.url} <Link className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.monitoring && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.CurrentUsage')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.CPU')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.cpuUsage > 80 ? "bg-destructive" : server.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.cpuUsage !== null && server.cpuUsage !== undefined ? `${server.cpuUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.RAM')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.ramUsage > 80 ? "bg-destructive" : server.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.ramUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.ramUsage !== null && server.ramUsage !== undefined ? `${server.ramUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Disk')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.diskUsage > 80 ? "bg-destructive" : server.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.diskUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.diskUsage !== null && server.diskUsage !== undefined ? `${server.diskUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
{server.gpuUsage && server.gpuUsage !== null && server.gpuUsage !== undefined && server.gpuUsage.toString() !== "0" && (
|
||||
<>
|
||||
<div className="text-muted-foreground">{t('Common.Server.GPU')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.gpuUsage && server.gpuUsage > 80 ? "bg-destructive" : server.gpuUsage && server.gpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.gpuUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.gpuUsage && server.gpuUsage !== null && server.gpuUsage !== undefined ? `${server.gpuUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{server.temp && server.temp !== null && server.temp !== undefined && server.temp.toString() !== "0" && (
|
||||
<>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Temperature')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.temp && server.temp > 80 ? "bg-destructive" : server.temp && server.temp > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${Math.min(server.temp || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.temp !== null && server.temp !== undefined && server.temp !== 0 ? `${server.temp}°C` : t('Common.noData')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Charts */}
|
||||
{server.monitoring && server.history && (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('Server.ResourceUsageHistory')}</CardTitle>
|
||||
<CardDescription>
|
||||
{timeRange === '1h'
|
||||
? t('Server.TimeRange.LastHour')
|
||||
: timeRange === '1d'
|
||||
? t('Server.TimeRange.Last24Hours')
|
||||
: timeRange === '7d'
|
||||
? t('Server.TimeRange.Last7Days')
|
||||
: t('Server.TimeRange.Last30Days')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={timeRange} onValueChange={(value: '1h' | '1d' | '7d' | '30d') => setTimeRange(value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('Server.TimeRange.Select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">{t('Server.TimeRange.LastHour')}</SelectItem>
|
||||
<SelectItem value="1d">{t('Server.TimeRange.Last24Hours')}</SelectItem>
|
||||
<SelectItem value="7d">{t('Server.TimeRange.Last7Days')}</SelectItem>
|
||||
<SelectItem value="30d">{t('Server.TimeRange.Last30Days')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={refreshData}>{t('Common.Refresh')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="cpu-chart" />
|
||||
</div>
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="ram-chart" />
|
||||
</div>
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="disk-chart" />
|
||||
</div>
|
||||
{server.history?.datasets.gpu.some(value => value !== null && value !== 0) && (
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="gpu-chart" />
|
||||
</div>
|
||||
)}
|
||||
{server.history?.datasets.temp.some(value => value !== null && value !== 0) && (
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="temp-chart" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Machines */}
|
||||
{server.hostedVMs && server.hostedVMs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Server.VirtualMachines')}</CardTitle>
|
||||
<CardDescription>{t('Server.VirtualMachinesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{server.hostedVMs.map((hostedVM) => (
|
||||
<div
|
||||
key={hostedVM.id}
|
||||
className="flex flex-col gap-2 border border-muted py-2 px-4 rounded-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{hostedVM.icon && (
|
||||
<DynamicIcon
|
||||
name={hostedVM.icon as any}
|
||||
size={24}
|
||||
/>
|
||||
)}
|
||||
<NextLink href={`/dashboard/servers/${hostedVM.id}`} className="hover:underline">
|
||||
<div className="text-base font-extrabold">
|
||||
{hostedVM.icon && "・ "}
|
||||
{hostedVM.name}
|
||||
</div>
|
||||
</NextLink>
|
||||
</div>
|
||||
{hostedVM.monitoring && (
|
||||
<div className="flex flex-col items-end">
|
||||
<StatusIndicator isOnline={hostedVM.online} />
|
||||
{hostedVM.online && hostedVM.uptime && (
|
||||
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
|
||||
{t('Common.since', { date: hostedVM.uptime })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-full pb-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5 pb-2">
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<MonitorCog className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.OS')}:</b> {hostedVM.os || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<FileDigit className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.IP')}:</b> {hostedVM.ip || t('Common.notSet')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full mb-2">
|
||||
<h4 className="text-sm font-semibold">{t('Server.HardwareInformation')}</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.CPU')}:</b> {hostedVM.cpu || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<Microchip className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.GPU')}:</b> {hostedVM.gpu || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.RAM')}:</b> {hostedVM.ram || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.Disk')}:</b> {hostedVM.disk || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hostedVM.monitoring && (
|
||||
<>
|
||||
<div className="col-span-full pt-2 pb-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="col-span-full grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.CPU')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.cpuUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.cpuUsage && hostedVM.cpuUsage > 80 ? "bg-destructive" : hostedVM.cpuUsage && hostedVM.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.cpuUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.RAM')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.ramUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.ramUsage && hostedVM.ramUsage > 80 ? "bg-destructive" : hostedVM.ramUsage && hostedVM.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.ramUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.Disk')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.diskUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.diskUsage && hostedVM.diskUsage > 80 ? "bg-destructive" : hostedVM.diskUsage && hostedVM.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.diskUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-12">
|
||||
<h2 className="text-2xl font-bold">{t('Server.NotFound')}</h2>
|
||||
<p className="text-muted-foreground mt-2">{t('Server.NotFoundDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
59
app/dashboard/servers/[server_id]/page.tsx
Normal file
59
app/dashboard/servers/[server_id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ServerDetail from "./Server"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' strokeWidth='1.4' strokeLinecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <ServerDetail /> : null;
|
||||
}
|
||||
59
app/dashboard/servers/page.tsx
Normal file
59
app/dashboard/servers/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Servers from "./Servers"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Servers /> : null;
|
||||
}
|
||||
868
app/dashboard/settings/Settings.tsx
Normal file
868
app/dashboard/settings/Settings.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
"use client"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useEffect, useState } from "react"
|
||||
import axios from "axios"
|
||||
import Cookies from "js-cookie"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2, Play, Languages } from "lucide-react"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { toast } from "sonner"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications: any[]
|
||||
}
|
||||
interface NotificationResponse {
|
||||
notification_text_application?: string
|
||||
notification_text_server?: string
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const t = useTranslations()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const [email, setEmail] = useState<string>("")
|
||||
const [password, setPassword] = useState<string>("")
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>("")
|
||||
const [oldPassword, setOldPassword] = useState<string>("")
|
||||
|
||||
const [emailError, setEmailError] = useState<string>("")
|
||||
const [passwordError, setPasswordError] = useState<string>("")
|
||||
const [emailErrorVisible, setEmailErrorVisible] = useState<boolean>(false)
|
||||
const [passwordErrorVisible, setPasswordErrorVisible] = useState<boolean>(false)
|
||||
|
||||
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
|
||||
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
|
||||
|
||||
const [notificationType, setNotificationType] = useState<string>("")
|
||||
const [notificationName, setNotificationName] = useState<string>("")
|
||||
const [smtpHost, setSmtpHost] = useState<string>("")
|
||||
const [smtpPort, setSmtpPort] = useState<number>(0)
|
||||
const [smtpSecure, setSmtpSecure] = useState<boolean>(false)
|
||||
const [smtpUsername, setSmtpUsername] = useState<string>("")
|
||||
const [smtpPassword, setSmtpPassword] = useState<string>("")
|
||||
const [smtpFrom, setSmtpFrom] = useState<string>("")
|
||||
const [smtpTo, setSmtpTo] = useState<string>("")
|
||||
const [telegramToken, setTelegramToken] = useState<string>("")
|
||||
const [telegramChatId, setTelegramChatId] = useState<string>("")
|
||||
const [discordWebhook, setDiscordWebhook] = useState<string>("")
|
||||
const [gotifyUrl, setGotifyUrl] = useState<string>("")
|
||||
const [gotifyToken, setGotifyToken] = useState<string>("")
|
||||
const [ntfyUrl, setNtfyUrl] = useState<string>("")
|
||||
const [ntfyToken, setNtfyToken] = useState<string>("")
|
||||
const [pushoverUrl, setPushoverUrl] = useState<string>("")
|
||||
const [pushoverToken, setPushoverToken] = useState<string>("")
|
||||
const [pushoverUser, setPushoverUser] = useState<string>("")
|
||||
const [echobellURL, setEchobellURL] = useState<string>("")
|
||||
const [language, setLanguage] = useState<string>("english")
|
||||
const [notifications, setNotifications] = useState<any[]>([])
|
||||
|
||||
const [notificationTextApplication, setNotificationTextApplication] = useState<string>("")
|
||||
const [notificationTextServer, setNotificationTextServer] = useState<string>("")
|
||||
|
||||
const changeEmail = async () => {
|
||||
setEmailErrorVisible(false)
|
||||
setEmailSuccess(false)
|
||||
setEmailError("")
|
||||
|
||||
if (!email) {
|
||||
setEmailError(t('Settings.UserSettings.ChangeEmail.EmailRequired'))
|
||||
setEmailErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setEmailErrorVisible(false)
|
||||
setEmailError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await axios.post("/api/auth/edit_email", {
|
||||
newEmail: email,
|
||||
jwtToken: Cookies.get("token"),
|
||||
})
|
||||
setEmailSuccess(true)
|
||||
setEmail("")
|
||||
setTimeout(() => {
|
||||
setEmailSuccess(false)
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
setEmailError(error.response.data.error)
|
||||
setEmailErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setEmailErrorVisible(false)
|
||||
setEmailError("")
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError(t('Settings.UserSettings.ChangePassword.PasswordsDontMatch'))
|
||||
setPasswordErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
if (!oldPassword || !password || !confirmPassword) {
|
||||
setPasswordError(t('Settings.UserSettings.ChangePassword.AllFieldsRequired'))
|
||||
setPasswordErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.post("/api/auth/edit_password", {
|
||||
oldPassword: oldPassword,
|
||||
newPassword: password,
|
||||
jwtToken: Cookies.get("token"),
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
setPasswordSuccess(true)
|
||||
setPassword("")
|
||||
setOldPassword("")
|
||||
setConfirmPassword("")
|
||||
setTimeout(() => {
|
||||
setPasswordSuccess(false)
|
||||
}, 3000)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setPasswordErrorVisible(true)
|
||||
setPasswordError(error.response.data.error)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const addNotification = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/add", {
|
||||
name: notificationName,
|
||||
type: notificationType,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpUsername: smtpUsername,
|
||||
smtpPassword: smtpPassword,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpTo: smtpTo,
|
||||
telegramToken: telegramToken,
|
||||
telegramChatId: telegramChatId,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
pushoverUrl: pushoverUrl,
|
||||
pushoverToken: pushoverToken,
|
||||
pushoverUser: pushoverUser,
|
||||
echobellURL: echobellURL,
|
||||
})
|
||||
getNotifications()
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/delete", {
|
||||
id: id,
|
||||
})
|
||||
if (response.status === 200) {
|
||||
getNotifications()
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotifications = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationsResponse>("/api/notifications/get", {})
|
||||
if (response.status === 200 && response.data) {
|
||||
setNotifications(response.data.notifications)
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotifications()
|
||||
}, [])
|
||||
|
||||
const getNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationResponse>("/api/settings/get_notification_text", {})
|
||||
if (response.status === 200) {
|
||||
if (response.data.notification_text_application) {
|
||||
setNotificationTextApplication(response.data.notification_text_application)
|
||||
} else {
|
||||
setNotificationTextApplication("The application !name (!url) is now !status.")
|
||||
}
|
||||
if (response.data.notification_text_server) {
|
||||
setNotificationTextServer(response.data.notification_text_server)
|
||||
} else {
|
||||
setNotificationTextServer("The server !name is now !status.")
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const editNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/settings/notification_text", {
|
||||
text_application: notificationTextApplication,
|
||||
text_server: notificationTextServer,
|
||||
})
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationText()
|
||||
}, [])
|
||||
|
||||
const testNotification = async (id: number) => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/test", {
|
||||
notificationId: id,
|
||||
})
|
||||
toast.success(t('Settings.Notifications.TestSuccess'))
|
||||
} catch (error: any) {
|
||||
toast.error(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const language = Cookies.get("language")
|
||||
if (language === "en") {
|
||||
setLanguage("english")
|
||||
} else if (language === "de") {
|
||||
setLanguage("german")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setLanguageFunc = (value: string) => {
|
||||
setLanguage(value)
|
||||
if (value === "english") {
|
||||
Cookies.set("language", "en")
|
||||
} else if (value === "german") {
|
||||
Cookies.set("language", "de")
|
||||
}
|
||||
// Reload the page
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage>{t('Settings.Breadcrumb.Dashboard')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Settings.Breadcrumb.Settings')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<div className="pb-4">
|
||||
<span className="text-3xl font-bold">{t('Settings.Title')}</span>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t('Settings.UserSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.UserSettings.Description')}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="font-semibold text-lg">{t('Settings.UserSettings.ChangeEmail.Title')}</h3>
|
||||
</div>
|
||||
|
||||
{emailErrorVisible && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t('Common.Error')}</AlertTitle>
|
||||
<AlertDescription>{emailError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{emailSuccess && (
|
||||
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertTitle>{t('Settings.UserSettings.ChangeEmail.Success')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('Settings.UserSettings.ChangeEmail.Placeholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Button onClick={changeEmail} className="w-full h-11">
|
||||
{t('Settings.UserSettings.ChangeEmail.Button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="font-semibold text-lg">{t('Settings.UserSettings.ChangePassword.Title')}</h3>
|
||||
</div>
|
||||
|
||||
{passwordErrorVisible && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t('Common.Error')}</AlertTitle>
|
||||
<AlertDescription>{passwordError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertTitle>{t('Settings.UserSettings.ChangePassword.Success')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.OldPassword')}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.NewPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.ConfirmPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Button onClick={changePassword} className="w-full h-11">
|
||||
{t('Settings.UserSettings.ChangePassword.Button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t('Settings.ThemeSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.ThemeSettings.Description')}
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select value={theme} onValueChange={(value: string) => setTheme(value)}>
|
||||
<SelectTrigger className="w-full h-11">
|
||||
<SelectValue>
|
||||
{t(`Settings.ThemeSettings.${(theme ?? "system").charAt(0).toUpperCase() + (theme ?? "system").slice(1)}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">{t('Settings.ThemeSettings.Light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('Settings.ThemeSettings.Dark')}</SelectItem>
|
||||
<SelectItem value="system">{t('Settings.ThemeSettings.System')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Languages className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t('Settings.LanguageSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.LanguageSettings.Description')}
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select value={language} onValueChange={(value: string) => setLanguageFunc(value)}>
|
||||
<SelectTrigger className="w-full h-11">
|
||||
<SelectValue>
|
||||
{t(`Settings.LanguageSettings.${(language ?? "english").charAt(0).toUpperCase() + (language ?? "english").slice(1)}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="english">{t('Settings.LanguageSettings.English')}</SelectItem>
|
||||
<SelectItem value="german">{t('Settings.LanguageSettings.German')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('Settings.Notifications.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.Notifications.Description')}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11 flex items-center gap-2">
|
||||
{t('Settings.Notifications.AddChannel')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t('Settings.Notifications.AddNotification.Title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
id="notificationName"
|
||||
placeholder={t('Settings.Notifications.AddNotification.Name')}
|
||||
onChange={(e) => setNotificationName(e.target.value)}
|
||||
/>
|
||||
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('Settings.Notifications.AddNotification.Type')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">{t('Settings.Notifications.AddNotification.SMTP.Title')}</SelectItem>
|
||||
<SelectItem value="telegram">{t('Settings.Notifications.AddNotification.Telegram.Title')}</SelectItem>
|
||||
<SelectItem value="discord">{t('Settings.Notifications.AddNotification.Discord.Title')}</SelectItem>
|
||||
<SelectItem value="gotify">{t('Settings.Notifications.AddNotification.Gotify.Title')}</SelectItem>
|
||||
<SelectItem value="ntfy">{t('Settings.Notifications.AddNotification.Ntfy.Title')}</SelectItem>
|
||||
<SelectItem value="pushover">{t('Settings.Notifications.AddNotification.Pushover.Title')}</SelectItem>
|
||||
<SelectItem value="echobell">{t('Settings.Notifications.AddNotification.Echobell.Title')}</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
{notificationType === "smtp" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Host')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="smtp.example.com"
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="587"
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2 pb-4">
|
||||
<Checkbox id="smtpSecure" onCheckedChange={(checked: any) => setSmtpSecure(checked)} />
|
||||
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
|
||||
{t('Settings.Notifications.AddNotification.SMTP.Secure')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Username')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="user@example.com"
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Password')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.From')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
onChange={(e) => setSmtpFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.To')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
onChange={(e) => setSmtpTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "telegram" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Telegram.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setTelegramToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Telegram.ChatId')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "discord" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Discord.Webhook')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setDiscordWebhook(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "gotify" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Gotify.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setGotifyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Gotify.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setGotifyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "ntfy" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Ntfy.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Ntfy.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "pushover" && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.User')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "echobell" && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Echobell.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. https://hook.echobell.one/t/xxx"
|
||||
onChange={(e) => setEchobellURL(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{t('Settings.Notifications.AddNotification.Echobell.AddMessage')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Select>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={addNotification}>{t('Common.add')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11" variant="outline">
|
||||
{t('Settings.Notifications.CustomizeText.Display')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t('Settings.Notifications.CustomizeText.Title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.CustomizeText.Application')}</Label>
|
||||
<Textarea
|
||||
value={notificationTextApplication}
|
||||
onChange={(e) => setNotificationTextApplication(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.CustomizeText.Server')}</Label>
|
||||
<Textarea
|
||||
value={notificationTextServer}
|
||||
onChange={(e) => setNotificationTextServer(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
{t('Settings.Notifications.CustomizeText.Placeholders.Title')}
|
||||
<ul className="list-disc list-inside space-y-1 pt-2">
|
||||
<li>
|
||||
<b>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Title')}</b>
|
||||
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Name')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Status')}</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Title')}</b>
|
||||
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Name')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Url')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Status')}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={editNotificationText}>
|
||||
{t('Common.Save')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4">{t('Settings.Notifications.ActiveChannels')}</h3>
|
||||
<div className="space-y-3">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{notification.type === "smtp" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<AtSign className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "telegram" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Send className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "discord" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "gotify" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "ntfy" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "pushover" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium capitalize">
|
||||
{notification.name ||
|
||||
t(`Settings.Notifications.AddNotification.${notification.type.charAt(0).toUpperCase() + notification.type.slice(1)}.Title`)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`Settings.Notifications.AddNotification.${notification.type.charAt(0).toUpperCase() + notification.type.slice(1)}.Description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-muted/20"
|
||||
onClick={() => testNotification(notification.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{t('Settings.Notifications.Test')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:bg-muted/20"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t('Settings.Notifications.Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/5">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="bg-muted/20 p-3 rounded-full">
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{t('Settings.Notifications.NoNotifications')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{t('Settings.Notifications.NoNotificationsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Toaster />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
59
app/dashboard/settings/page.tsx
Normal file
59
app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Settings from "./Settings"
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Settings /> : null;
|
||||
}
|
||||
434
app/dashboard/uptime/Uptime.tsx
Normal file
434
app/dashboard/uptime/Uptime.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import axios from "axios";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationLink,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const timeFormats = {
|
||||
1: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
2: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
3: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleDateString([], {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
}),
|
||||
4: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleDateString([], {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
};
|
||||
|
||||
const minBoxWidths = {
|
||||
1: 20,
|
||||
2: 20,
|
||||
3: 24,
|
||||
4: 24
|
||||
};
|
||||
|
||||
interface UptimeData {
|
||||
appName: string;
|
||||
appId: number;
|
||||
uptimeSummary: {
|
||||
timestamp: string;
|
||||
missing: boolean;
|
||||
online: boolean | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export default function Uptime() {
|
||||
const t = useTranslations();
|
||||
const [data, setData] = useState<UptimeData[]>([]);
|
||||
const [timespan, setTimespan] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [pagination, setPagination] = useState<PaginationData>({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const savedItemsPerPage = Cookies.get("itemsPerPage-uptime");
|
||||
const defaultItemsPerPage = 5;
|
||||
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
|
||||
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getData = async (selectedTimespan: number, page: number, itemsPerPage: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.post<{
|
||||
data: UptimeData[];
|
||||
pagination: PaginationData;
|
||||
}>("/api/applications/uptime", {
|
||||
timespan: selectedTimespan,
|
||||
page,
|
||||
itemsPerPage
|
||||
});
|
||||
|
||||
setData(response.data.data);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
setData([]);
|
||||
setPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const newPage = Math.max(1, pagination.currentPage - 1);
|
||||
setPagination(prev => ({...prev, currentPage: newPage}));
|
||||
getData(timespan, newPage, itemsPerPage);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
|
||||
setPagination(prev => ({...prev, currentPage: newPage}));
|
||||
getData(timespan, newPage, itemsPerPage);
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const newItemsPerPage = parseInt(value);
|
||||
|
||||
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
|
||||
toast.error(t('Uptime.Messages.NumberValidation'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
|
||||
|
||||
setItemsPerPage(validatedValue);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
|
||||
expires: 365,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
getData(timespan, 1, validatedValue);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getData(timespan, 1, itemsPerPage);
|
||||
}, [timespan]);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage>/</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Uptime.Breadcrumb.MyInfrastructure')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Uptime.Breadcrumb.Uptime')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<Toaster />
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-3xl font-bold">{t('Uptime.Title')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={String(itemsPerPage)}
|
||||
onValueChange={handleItemsPerPageChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && customInputRef.current) {
|
||||
customInputRef.current.value = String(itemsPerPage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue>
|
||||
{itemsPerPage} {itemsPerPage === 1 ? t('Common.ItemsPerPage.item') : t('Common.ItemsPerPage.items')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
|
||||
<SelectItem value={String(itemsPerPage)}>
|
||||
{itemsPerPage} {itemsPerPage === 1 ? t('Common.ItemsPerPage.item') : t('Common.ItemsPerPage.items')} (custom)
|
||||
</SelectItem>
|
||||
) : null}
|
||||
<SelectItem value="5">{t('Common.ItemsPerPage.5')}</SelectItem>
|
||||
<SelectItem value="10">{t('Common.ItemsPerPage.10')}</SelectItem>
|
||||
<SelectItem value="15">{t('Common.ItemsPerPage.15')}</SelectItem>
|
||||
<SelectItem value="20">{t('Common.ItemsPerPage.20')}</SelectItem>
|
||||
<SelectItem value="25">{t('Common.ItemsPerPage.25')}</SelectItem>
|
||||
<div className="p-2 border-t mt-1">
|
||||
<Label htmlFor="custom-items" className="text-xs font-medium">{t('Common.ItemsPerPage.Custom')}</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Input
|
||||
id="custom-items"
|
||||
ref={customInputRef}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
className="h-8"
|
||||
defaultValue={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (isNaN(value) || value < 1 || value > 100) {
|
||||
e.target.classList.add("border-red-500");
|
||||
} else {
|
||||
e.target.classList.remove("border-red-500");
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value >= 1 && value <= 100) {
|
||||
handleItemsPerPageChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
const value = parseInt((e.target as HTMLInputElement).value);
|
||||
if (value >= 1 && value <= 100) {
|
||||
const validatedValue = Math.min(Math.max(value, 1), 100);
|
||||
setItemsPerPage(validatedValue);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
|
||||
expires: 365,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
getData(timespan, 1, validatedValue);
|
||||
document.body.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{t('Common.ItemsPerPage.items')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(timespan)}
|
||||
onValueChange={(v) => {
|
||||
setTimespan(Number(v) as 1 | 2 | 3 | 4);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('Uptime.TimeRange.Select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">{t('Uptime.TimeRange.LastHour')}</SelectItem>
|
||||
<SelectItem value="2">{t('Uptime.TimeRange.LastDay')}</SelectItem>
|
||||
<SelectItem value="3">{t('Uptime.TimeRange.Last7Days')}</SelectItem>
|
||||
<SelectItem value="4">{t('Uptime.TimeRange.Last30Days')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">{t('Uptime.Messages.Loading')}</div>
|
||||
) : (
|
||||
data.map((app) => {
|
||||
const reversedSummary = [...app.uptimeSummary].reverse();
|
||||
const startTime = reversedSummary[0]?.timestamp;
|
||||
const endTime = reversedSummary[reversedSummary.length - 1]?.timestamp;
|
||||
|
||||
return (
|
||||
<Card key={app.appId}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold">{app.appName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{startTime ? timeFormats[timespan](startTime) : ""}</span>
|
||||
<span>{endTime ? timeFormats[timespan](endTime) : ""}</span>
|
||||
</div>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<div
|
||||
className="grid gap-0.5 w-full pb-2"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${minBoxWidths[timespan]}px, 1fr))`
|
||||
}}
|
||||
>
|
||||
{reversedSummary.map((entry) => (
|
||||
<Tooltip.Root key={entry.timestamp}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className={`h-8 w-full rounded-sm border transition-colors ${
|
||||
entry.missing
|
||||
? "bg-gray-300 border-gray-400"
|
||||
: entry.online
|
||||
? "bg-green-500 border-green-600"
|
||||
: "bg-red-500 border-red-600"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="rounded bg-gray-900 px-2 py-1 text-white text-xs shadow-lg"
|
||||
side="top"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">
|
||||
{new Date(entry.timestamp).toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: timespan > 2 ? 'numeric' : undefined,
|
||||
hour: '2-digit',
|
||||
minute: timespan === 1 ? '2-digit' : undefined,
|
||||
hour12: false
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{entry.missing
|
||||
? t('Uptime.Status.NoData')
|
||||
: entry.online
|
||||
? t('Uptime.Status.Online')
|
||||
: t('Uptime.Status.Offline')}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-gray-900" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination.totalItems > 0 && !isLoading && (
|
||||
<div className="pt-4 pb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pagination.totalItems > 0
|
||||
? t('Uptime.Pagination.Showing', {
|
||||
start: ((pagination.currentPage - 1) * itemsPerPage) + 1,
|
||||
end: Math.min(pagination.currentPage * itemsPerPage, pagination.totalItems),
|
||||
total: pagination.totalItems
|
||||
})
|
||||
: t('Uptime.Messages.NoItems')}
|
||||
</div>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={handlePrevious}
|
||||
aria-disabled={pagination.currentPage === 1 || isLoading}
|
||||
className={
|
||||
pagination.currentPage === 1 || isLoading
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink isActive>{pagination.currentPage}</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={handleNext}
|
||||
aria-disabled={pagination.currentPage === pagination.totalPages || isLoading}
|
||||
className={
|
||||
pagination.currentPage === pagination.totalPages || isLoading
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
59
app/dashboard/uptime/page.tsx
Normal file
59
app/dashboard/uptime/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Uptime from "./Uptime";
|
||||
import axios from "axios";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
router.push("/");
|
||||
} else {
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/auth/validate", {
|
||||
token: token,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsValid(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
Cookies.remove("token");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
checkToken();
|
||||
}
|
||||
setIsAuthChecked(true);
|
||||
}, [router]);
|
||||
|
||||
if (!isAuthChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className='inline-block' role='status' aria-label='loading'>
|
||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#clip0_9023_61563)'>
|
||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_9023_61563'>
|
||||
<rect width='24' height='24' fill='white'></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return isValid ? <Uptime /> : null;
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
120
app/globals.css
Normal file
120
app/globals.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.147 0.004 49.25);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.147 0.004 49.25);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||
--primary: oklch(0.216 0.006 56.043);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.97 0.001 106.424);
|
||||
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||
--muted: oklch(0.97 0.001 106.424);
|
||||
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||
--accent: oklch(0.97 0.001 106.424);
|
||||
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.923 0.003 48.717);
|
||||
--input: oklch(0.923 0.003 48.717);
|
||||
--ring: oklch(0.709 0.01 56.259);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.001 106.423);
|
||||
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.147 0.004 49.25);
|
||||
--foreground: oklch(0.985 0.001 106.423);
|
||||
--card: oklch(0.216 0.006 56.043);
|
||||
--card-foreground: oklch(0.985 0.001 106.423);
|
||||
--popover: oklch(0.216 0.006 56.043);
|
||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||
--primary: oklch(0.923 0.003 48.717);
|
||||
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||
--secondary: oklch(0.268 0.007 34.298);
|
||||
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||
--muted: oklch(0.268 0.007 34.298);
|
||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||
--accent: oklch(0.268 0.007 34.298);
|
||||
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.553 0.013 58.071);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.216 0.006 56.043);
|
||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
51
app/layout.tsx
Normal file
51
app/layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import {NextIntlClientProvider} from 'next-intl';
|
||||
import {getLocale} from 'next-intl/server';
|
||||
import {cookies} from 'next/headers';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CoreControl",
|
||||
description: "The only Dashboard you will need for your Services",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get('language')?.value || 'en';
|
||||
const messages = (await import(`@/i18n/languages/${locale}.json`)).default;
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
150
app/page.tsx
Normal file
150
app/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Cookies from "js-cookie"
|
||||
import { useRouter } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import { AlertCircle, KeyRound, Mail, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('Home');
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [rememberMe, setRememberMe] = useState(false)
|
||||
const router = useRouter()
|
||||
const [error, setError] = useState("")
|
||||
const [errorVisible, setErrorVisible] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token")
|
||||
if (token) {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
if (!username || !password) {
|
||||
setError("Please enter both email and password")
|
||||
setErrorVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await axios.post("/api/auth/login", { username, password })
|
||||
const { token } = response.data as LoginResponse
|
||||
|
||||
const cookieOptions = rememberMe ? { expires: 7 } : {}
|
||||
Cookies.set("token", token, cookieOptions)
|
||||
|
||||
router.push("/dashboard")
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || "Login failed. Please try again.")
|
||||
setErrorVisible(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">CoreControl</h1>
|
||||
<p className="text-muted-foreground">{t('LoginCardDescription')}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-muted/40 shadow-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-semibold">{t('LoginCardTitle')}</CardTitle>
|
||||
<CardDescription>{t('LoginCardDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{errorVisible && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50 slide-in-from-top-5">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t('AuthenticationError')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
{t('Email')}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="mail@example.com"
|
||||
className="pl-10"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
{t('Password')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="pl-10"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button className="w-full" onClick={login} disabled={isLoading}>
|
||||
{isLoading ? t('SigninButtonSigningIn') : t('SigninButton')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user