34 Commits

Author SHA1 Message Date
headlessdev
63e8744e78 v0.0.7->main
v0.0.7
2025-04-19 16:34:14 +02:00
headlessdev
e39402ff70 small notification text fix 2025-04-19 16:23:53 +02:00
headlessdev
7d49baee6b Update server count query to filter by hostServer set to 0 and null to fix pagination 2025-04-19 16:20:59 +02:00
headlessdev
ceb10a2ffe Update server name display to conditionally include an icon indicator for better visual context. 2025-04-19 16:18:44 +02:00
headlessdev
7986737e0e Update Applications component layout by adjusting width of URL buttons and refining button labels for clarity. 2025-04-19 16:15:56 +02:00
headlessdev
f6debb1629 AAdjusted server name display to include an icon indicator and refined button alignment for management URL access. 2025-04-19 16:12:41 +02:00
headlessdev
8259563c33 Refactor Servers component to improve code readability and structure. Removed unused imports, standardized import statements, and updated state variable declarations for consistency. Enhanced icon selection functionality in the server editing interface. 2025-04-19 15:58:48 +02:00
headlessdev
3580f7f640 Add icon field to AddRequest and EditRequest interfaces, and update Dashboard component to support icon selection and display. Enhanced server creation and editing functionality with icon integration. 2025-04-19 15:46:01 +02:00
headlessdev
b655b7fe2d Add optional icon field to server model in Prisma schema 2025-04-19 15:17:20 +02:00
headlessdev
b42c1a45cc Refactor Servers component by removing unused imports and cleaning up code structure 2025-04-19 15:07:34 +02:00
headlessdev
86d48bc082 VMs are now shown in the search results 2025-04-19 14:39:02 +02:00
headlessdev
44817d6685 Adjust flowchart spacing constants for improved layout and readability 2025-04-19 14:26:42 +02:00
headlessdev
62c27118d6 Add support for Gotify and Ntfy notification types in the agent module
- Introduced new fields for Gotify and Ntfy URLs and tokens in the Notification struct.
- Updated the loadNotifications function to retrieve Gotify and Ntfy data from the database.
- Implemented sendGotify and sendNtfy functions to handle sending notifications via Gotify and Ntfy services.
- Enhanced the sendNotifications function to include logic for sending messages through Gotify and Ntfy.
2025-04-19 13:48:52 +02:00
headlessdev
016c9a2562 Remove color styling from server count display in Dashboard 2025-04-19 13:24:08 +02:00
headlessdev
b835ded157 Add notification type descriptions and icons for Gotify and Ntfy in Settings component 2025-04-19 13:21:39 +02:00
headlessdev
016d52fa1b Remove Bell icon from Add Notification Channel button in Settings component 2025-04-19 13:20:47 +02:00
headlessdev
2b8f7a95d2 Add Gotify and Ntfy configuration fields to Settings component for enhanced notification options 2025-04-19 13:20:04 +02:00
headlessdev
300547e59e Add Gotify and ntfy fields to AddRequest interface and update POST method to handle new notification types 2025-04-19 13:11:18 +02:00
headlessdev
93bffa29cc Add new fields for Gotify and ntfy integration in notification model 2025-04-19 13:07:09 +02:00
headlessdev
2a910c165e Add disabled state and warning message for host server checkbox in Dashboard 2025-04-19 00:29:45 +02:00
headlessdev
6412cbaf1c Filter out the currently edited server from the host server selection in the Dashboard component for improved user experience. 2025-04-19 00:28:22 +02:00
headlessdev
d9304001fe Fix ScrollArea width in Dashboard server card for better layout consistency 2025-04-19 00:20:38 +02:00
headlessdev
7d7897c3f6 Update server card title in Dashboard for clarity 2025-04-19 00:18:02 +02:00
headlessdev
1ae55da3f9 UI improvements server card 2025-04-19 00:17:41 +02:00
headlessdev
113bb3bfb4 Enhance Dashboard UI for server management by updating card layout, improving titles, and adding descriptions for physical and virtual servers. 2025-04-19 00:16:23 +02:00
headlessdev
83ea20545d Update server count handling in Dashboard component to separate physical servers and VMs 2025-04-19 00:13:12 +02:00
headlessdev
965f79f31a Refactor server count retrieval in POST request to differentiate between servers with and without VMs 2025-04-19 00:00:10 +02:00
headlessdev
f1c0cc9deb Network VM spacing fix 2025-04-18 23:57:10 +02:00
headlessdev
f2535cd2b9 Refactor hostServer assignment in PUT request to handle null values correctly 2025-04-18 23:48:29 +02:00
headlessdev
42e584a381 Updated Flowchart 2025-04-18 23:46:20 +02:00
headlessdev
0e1f9edaab improved ui for setting notifications card 2025-04-18 23:03:05 +02:00
headlessdev
c3fe3bc03d Update version to 0.0.7 in package.json 2025-04-18 22:53:02 +02:00
headlessdev
67097725d7 Remove deprecated @next/swc-win32-x64-msvc module from package-lock.json 2025-04-18 22:50:38 +02:00
headlessdev
61468a359d Update README.md 2025-04-18 17:12:49 +02:00
20 changed files with 1784 additions and 1186 deletions

View File

@@ -68,6 +68,9 @@ services:
image: haedlessdev/corecontrol-agent:latest image: haedlessdev/corecontrol-agent:latest
environment: environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
db:
condition: service_healthy
db: db:
image: postgres:17 image: postgres:17
@@ -78,6 +81,11 @@ services:
POSTGRES_DB: postgres POSTGRES_DB: postgres
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -1,9 +1,11 @@
package main package main
import ( import (
"bytes"
"context" "context"
"crypto/x509" "crypto/x509"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -40,6 +42,10 @@ type Notification struct {
TelegramChatID sql.NullString TelegramChatID sql.NullString
TelegramToken sql.NullString TelegramToken sql.NullString
DiscordWebhook sql.NullString DiscordWebhook sql.NullString
GotifyUrl sql.NullString
GotifyToken sql.NullString
NtfyUrl sql.NullString
NtfyToken sql.NullString
} }
var ( var (
@@ -134,7 +140,7 @@ func isIPAddress(host string) bool {
func loadNotifications(db *sql.DB) ([]Notification, error) { func loadNotifications(db *sql.DB) ([]Notification, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo", `SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
"telegramChatId", "telegramToken", "discordWebhook" "telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken"
FROM notification FROM notification
WHERE enabled = true`, WHERE enabled = true`,
) )
@@ -149,7 +155,7 @@ func loadNotifications(db *sql.DB) ([]Notification, error) {
if err := rows.Scan( if err := rows.Scan(
&n.ID, &n.Enabled, &n.Type, &n.ID, &n.Enabled, &n.Type,
&n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo, &n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo,
&n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook, &n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook, &n.GotifyUrl, &n.GotifyToken, &n.NtfyUrl, &n.NtfyToken,
); err != nil { ); err != nil {
fmt.Printf("Error scanning notification: %v\n", err) fmt.Printf("Error scanning notification: %v\n", err)
continue continue
@@ -200,7 +206,7 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
var notificationTemplate string var notificationTemplate string
err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate) err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate)
if err != nil || notificationTemplate == "" { if err != nil || notificationTemplate == "" {
notificationTemplate = "The application '!name' (!url) went !status!" notificationTemplate = "The application !name (!url) went !status!"
} }
for _, app := range apps { for _, app := range apps {
@@ -314,6 +320,14 @@ func sendNotifications(message string) {
if n.DiscordWebhook.Valid { if n.DiscordWebhook.Valid {
sendDiscord(n, message) sendDiscord(n, message)
} }
case "gotify":
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
sendGotify(n, message)
}
case "ntfy":
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
sendNtfy(n, message)
}
} }
} }
} }
@@ -371,3 +385,69 @@ func sendDiscord(n Notification, message string) {
} }
resp.Body.Close() resp.Body.Close()
} }
func sendGotify(n Notification, message string) {
baseURL := strings.TrimSuffix(n.GotifyUrl.String, "/")
targetURL := fmt.Sprintf("%s/message", baseURL)
form := url.Values{}
form.Add("message", message)
form.Add("priority", "5")
req, err := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
if err != nil {
fmt.Printf("Gotify: ERROR creating request: %v\n", err)
return
}
req.Header.Set("X-Gotify-Key", n.GotifyToken.String)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Gotify: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Gotify: ERROR status code: %d\n", resp.StatusCode)
}
}
func sendNtfy(n Notification, message string) {
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
topic := "corecontrol"
requestURL := fmt.Sprintf("%s/%s", baseURL, topic)
payload := map[string]string{"message": message}
jsonData, err := json.Marshal(payload)
if err != nil {
fmt.Printf("Ntfy: ERROR marshaling JSON: %v\n", err)
return
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Ntfy: ERROR creating request: %v\n", err)
return
}
if n.NtfyToken.Valid {
req.Header.Set("Authorization", "Bearer "+n.NtfyToken.String)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Ntfy: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode)
}
}

View File

@@ -3,7 +3,19 @@ import { prisma } from "@/lib/prisma";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const serverCount = await prisma.server.count(); 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 applicationCount = await prisma.application.count();
@@ -12,7 +24,8 @@ export async function POST(request: NextRequest) {
}); });
return NextResponse.json({ return NextResponse.json({
serverCount, serverCountNoVMs,
serverCountOnlyVMs,
applicationCount, applicationCount,
onlineApplicationsCount onlineApplicationsCount
}); });

View File

@@ -30,6 +30,8 @@ interface Server {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
host: boolean;
hostServer: number | null;
} }
interface Application { interface Application {
@@ -43,11 +45,15 @@ const NODE_WIDTH = 220;
const NODE_HEIGHT = 60; const NODE_HEIGHT = 60;
const APP_NODE_WIDTH = 160; const APP_NODE_WIDTH = 160;
const APP_NODE_HEIGHT = 40; const APP_NODE_HEIGHT = 40;
const HORIZONTAL_SPACING = 280; const HORIZONTAL_SPACING = 700;
const VERTICAL_SPACING = 60; const VERTICAL_SPACING = 80;
const START_Y = 120; const START_Y = 120;
const ROOT_NODE_WIDTH = 300; const ROOT_NODE_WIDTH = 300;
const CONTAINER_PADDING = 40; 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() { export async function GET() {
try { try {
@@ -60,74 +66,138 @@ export async function GET() {
}) as Promise<Application[]>, }) as Promise<Application[]>,
]); ]);
// Root Node // Level 2: Physical Servers
const rootNode: Node = { const serverNodes: Node[] = servers
id: "root", .filter(server => !server.hostServer)
type: "infrastructure", .map((server, index, filteredServers) => {
data: { label: "My Infrastructure" }, const xPos =
position: { x: 0, y: 0 }, index * HORIZONTAL_SPACING -
style: { ((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
background: "#ffffff",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "8px",
padding: "16px",
width: ROOT_NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "1.2rem",
fontWeight: "bold",
},
};
// Server Nodes return {
const serverNodes: Node[] = servers.map((server, index) => { id: `server-${server.id}`,
const xPos = type: "server",
index * HORIZONTAL_SPACING - data: {
((servers.length - 1) * HORIZONTAL_SPACING) / 2; 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",
},
};
});
return { // Level 3: Services and VMs
id: `server-${server.id}`, const serviceNodes: Node[] = [];
type: "server", const vmNodes: Node[] = [];
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",
},
};
});
// Application Nodes
const appNodes: Node[] = [];
servers.forEach((server) => { servers.forEach((server) => {
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`); const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
const serverX = serverNode?.position.x || 0; if (serverNode) {
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2; 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 applications
.filter((app) => app.serverId === server.id) .filter(app => app.serverId === vm.data.id)
.forEach((app, appIndex) => { .forEach((app, appIndex) => {
appNodes.push({ vmAppNodes.push({
id: `app-${app.id}`, id: `vm-app-${app.id}`,
type: "application", type: "application",
data: { data: {
label: `${app.name}\n${app.localURL}`, label: `${app.name}\n${app.localURL}`,
...app, ...app,
}, },
position: { position: {
x: serverX + xOffset, x: vmX + VM_APP_SPACING,
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING, y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
}, },
style: { style: {
background: "#f5f5f5", background: "#f5f5f5",
@@ -145,38 +215,14 @@ export async function GET() {
}); });
}); });
// Connections // Calculate dimensions for root node positioning
const connections: Edge[] = [ const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
...servers.map((server) => ({
id: `conn-root-${server.id}`,
source: "root",
target: `server-${server.id}`,
type: "straight",
style: {
stroke: "#94a3b8",
strokeWidth: 2,
},
})),
...applications.map((app) => ({
id: `conn-${app.serverId}-${app.id}`,
source: `server-${app.serverId}`,
target: `app-${app.id}`,
type: "straight",
style: {
stroke: "#60a5fa",
strokeWidth: 2,
},
})),
];
// Container Box
const allNodes = [rootNode, ...serverNodes, ...appNodes];
let minX = Infinity; let minX = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let minY = Infinity; let minY = Infinity;
let maxY = -Infinity; let maxY = -Infinity;
allNodes.forEach((node) => { tempNodes.forEach((node) => {
const width = parseInt(node.style.width?.toString() || "0", 10); const width = parseInt(node.style.width?.toString() || "0", 10);
const height = parseInt(node.style.height?.toString() || "0", 10); const height = parseInt(node.style.height?.toString() || "0", 10);
@@ -186,17 +232,47 @@ export async function GET() {
maxY = Math.max(maxY, node.position.y + height); 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 = { const containerNode: Node = {
id: 'container', id: 'container',
type: 'container', type: 'container',
data: { label: '' }, data: { label: '' },
position: { position: {
x: minX - CONTAINER_PADDING, x: newMinX - CONTAINER_PADDING,
y: minY - CONTAINER_PADDING y: newMinY - CONTAINER_PADDING
}, },
style: { style: {
width: maxX - minX + 2 * CONTAINER_PADDING, width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
height: maxY - minY + 2 * CONTAINER_PADDING, height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
background: 'transparent', background: 'transparent',
border: '2px dashed #e2e8f0', border: '2px dashed #e2e8f0',
borderRadius: '8px', borderRadius: '8px',
@@ -207,6 +283,116 @@ export async function GET() {
zIndex: -1, 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({ return NextResponse.json({
nodes: [containerNode, ...allNodes], nodes: [containerNode, ...allNodes],
edges: connections, edges: connections,

View File

@@ -13,12 +13,16 @@ interface AddRequest {
telegramToken?: string; telegramToken?: string;
telegramChatId?: string; telegramChatId?: string;
discordWebhook?: string; discordWebhook?: string;
gotifyUrl?: string;
gotifyToken?: string;
ntfyUrl?: string;
ntfyToken?: string;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: AddRequest = await request.json(); const body: AddRequest = await request.json();
const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook } = body; const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken } = body;
const notification = await prisma.notification.create({ const notification = await prisma.notification.create({
data: { data: {
@@ -33,6 +37,10 @@ export async function POST(request: NextRequest) {
telegramChatId: telegramChatId, telegramChatId: telegramChatId,
telegramToken: telegramToken, telegramToken: telegramToken,
discordWebhook: discordWebhook, discordWebhook: discordWebhook,
gotifyUrl: gotifyUrl,
gotifyToken: gotifyToken,
ntfyUrl: ntfyUrl,
ntfyToken: ntfyToken,
} }
}); });

View File

@@ -5,6 +5,7 @@ interface AddRequest {
host: boolean; host: boolean;
hostServer: number; hostServer: number;
name: string; name: string;
icon: string;
os: string; os: string;
ip: string; ip: string;
url: string; url: string;
@@ -18,13 +19,14 @@ interface AddRequest {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: AddRequest = await request.json(); const body: AddRequest = await request.json();
const { host, hostServer, name, os, ip, url, cpu, gpu, ram, disk } = body; const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk } = body;
const server = await prisma.server.create({ const server = await prisma.server.create({
data: { data: {
host, host,
hostServer, hostServer,
name, name,
icon,
os, os,
ip, ip,
url, url,

View File

@@ -6,6 +6,7 @@ interface EditRequest {
hostServer: number; hostServer: number;
id: number; id: number;
name: string; name: string;
icon: string;
os: string; os: string;
ip: string; ip: string;
url: string; url: string;
@@ -18,19 +19,27 @@ interface EditRequest {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const body: EditRequest = await request.json(); const body: EditRequest = await request.json();
const { host, hostServer, id, name, os, ip, url, cpu, gpu, ram, disk } = body; const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk } = body;
const existingServer = await prisma.server.findUnique({ where: { id } }); const existingServer = await prisma.server.findUnique({ where: { id } });
if (!existingServer) { if (!existingServer) {
return NextResponse.json({ error: "Server not found" }, { status: 404 }); 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({ const updatedServer = await prisma.server.update({
where: { id }, where: { id },
data: { data: {
host, host,
hostServer, hostServer: newHostServer,
name, name,
icon,
os, os,
ip, ip,
url, url,

View File

@@ -20,17 +20,29 @@ export async function POST(request: NextRequest) {
}); });
const hostsWithVms = await Promise.all( const hostsWithVms = await Promise.all(
hosts.map(async (host) => ({ hosts.map(async (host) => {
...host, const vms = await prisma.server.findMany({
hostedVMs: await prisma.server.findMany({
where: { hostServer: host.id }, where: { hostServer: host.id },
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}) });
}))
// Add isVM flag to VMs
const vmsWithFlag = vms.map(vm => ({
...vm,
isVM: true,
hostedVMs: [] // Initialize empty hostedVMs array for VMs
}));
return {
...host,
isVM: false, // Mark as physical server/not a VM
hostedVMs: vmsWithFlag
};
})
); );
const totalHosts = await prisma.server.count({ const totalHosts = await prisma.server.count({
where: { hostServer: null } where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
}); });
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE); const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);

View File

@@ -6,7 +6,15 @@ export async function GET(request: NextRequest) {
const servers = await prisma.server.findMany({ const servers = await prisma.server.findMany({
where: { host: true }, where: { host: true },
}); });
return NextResponse.json({ servers });
// 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) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }

View File

@@ -11,15 +11,51 @@ export async function POST(request: NextRequest) {
const body: SearchRequest = await request.json(); const body: SearchRequest = await request.json();
const { searchterm } = body; const { searchterm } = body;
// Fetch all servers
const servers = await prisma.server.findMany({}); 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 = { const fuseOptions = {
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk'], keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
threshold: 0.3, threshold: 0.3,
includeScore: true, includeScore: true,
}; };
const fuse = new Fuse(servers, fuseOptions); const fuse = new Fuse(serversWithType, fuseOptions);
const searchResults = fuse.search(searchterm); const searchResults = fuse.search(searchterm);

View File

@@ -19,20 +19,23 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
interface StatsResponse { interface StatsResponse {
serverCount: number serverCountNoVMs: number
serverCountOnlyVMs: number
applicationCount: number applicationCount: number
onlineApplicationsCount: number onlineApplicationsCount: number
} }
export default function Dashboard() { export default function Dashboard() {
const [serverCount, setServerCount] = useState<number>(0) const [serverCountNoVMs, setServerCountNoVMs] = useState<number>(0)
const [serverCountOnlyVMs, setServerCountOnlyVMs] = useState<number>(0)
const [applicationCount, setApplicationCount] = useState<number>(0) const [applicationCount, setApplicationCount] = useState<number>(0)
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0) const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
const getStats = async () => { const getStats = async () => {
try { try {
const response = await axios.post<StatsResponse>("/api/dashboard/get", {}) const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
setServerCount(response.data.serverCount) setServerCountNoVMs(response.data.serverCountNoVMs)
setServerCountOnlyVMs(response.data.serverCountOnlyVMs)
setApplicationCount(response.data.applicationCount) setApplicationCount(response.data.applicationCount)
setOnlineApplicationsCount(response.data.onlineApplicationsCount) setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) { } catch (error: any) {
@@ -69,24 +72,54 @@ export default function Dashboard() {
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2"> <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-sm transition-all hover:shadow-md"> <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="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Servers</CardTitle> <div>
<Server className="h-6 w-6 text-rose-500" /> <CardTitle className="text-2xl font-semibold">Servers</CardTitle>
<CardDescription className="mt-1">Physical and virtual servers overview</CardDescription>
</div> </div>
<CardDescription>Manage your server infrastructure</CardDescription> <Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
</CardHeader> </div>
<CardContent className="pt-2 pb-4"> </CardHeader>
<div className="text-4xl font-bold">{serverCount}</div> <CardContent className="pt-2 pb-4">
<p className="text-sm text-muted-foreground mt-2">Active servers</p> <div className="grid grid-cols-2 gap-4">
</CardContent> {/* Physical Servers */}
<CardFooter className="border-t bg-muted/20 p-4"> <div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild> <div className="bg-rose-100 p-2 rounded-full">
<Link href="/dashboard/servers">View all servers</Link> <Server className="h-6 w-6 text-rose-600" />
</Button> </div>
</CardFooter> <div>
</Card> <div className="text-3xl font-bold">{serverCountNoVMs}</div>
<p className="text-sm text-muted-foreground">Physical Servers</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">Virtual Servers</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="border-t bg-muted/10 p-4">
<Button
variant="ghost"
size="lg"
className="w-full hover:bg-rose-50font-semibold transition-colors"
asChild
>
<Link href="/dashboard/servers" className="flex items-center justify-between">
<span>Manage Servers</span>
</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md"> <Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -152,7 +185,7 @@ export default function Dashboard() {
<CardDescription>Manage network configuration</CardDescription> <CardDescription>Manage network configuration</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-2 pb-4"> <CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount + applicationCount}</div> <div className="text-4xl font-bold">{serverCountNoVMs + serverCountOnlyVMs + applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active connections</p> <p className="text-sm text-muted-foreground mt-2">Active connections</p>
</CardContent> </CardContent>
<CardFooter className="border-t bg-muted/20 p-4"> <CardFooter className="border-t bg-muted/20 p-4">

View File

@@ -479,7 +479,7 @@ export default function Dashboard() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]"> <div className="flex flex-col items-end justify-start space-y-2 w-[190px]">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<div className="flex flex-col space-y-2 flex-grow"> <div className="flex flex-col space-y-2 flex-grow">
<Button <Button
@@ -490,7 +490,7 @@ export default function Dashboard() {
} }
> >
<Link className="h-4 w-4" /> <Link className="h-4 w-4" />
Open Public URL Public URL
</Button> </Button>
{app.localURL && ( {app.localURL && (
<Button <Button
@@ -501,7 +501,7 @@ export default function Dashboard() {
} }
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
Open Local URL Local URL
</Button> </Button>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,25 @@
import { AppSidebar } from "@/components/app-sidebar"; "use client"
import { AppSidebar } from "@/components/app-sidebar"
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator"
import { import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
SidebarInset, import { Card, CardContent, CardHeader } from "@/components/ui/card"
SidebarProvider, import { useTheme } from "next-themes"
SidebarTrigger, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
} 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 { Input } from "@/components/ui/input"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import axios from "axios"; import axios from "axios"
import Cookies from "js-cookie"; import Cookies from "js-cookie"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertCircle, Check, Palette, User, Bell } from "lucide-react"; import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2 } from "lucide-react"
import { import {
AlertDialog, AlertDialog,
@@ -39,19 +31,19 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox"
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea"
interface NotificationsResponse { interface NotificationsResponse {
notifications: any[]; notifications: any[]
} }
interface NotificationResponse { interface NotificationResponse {
notification_text?: string; notification_text?: string
} }
export default function Settings() { export default function Settings() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme()
const [email, setEmail] = useState<string>("") const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("") const [password, setPassword] = useState<string>("")
@@ -77,95 +69,98 @@ export default function Settings() {
const [telegramToken, setTelegramToken] = useState<string>("") const [telegramToken, setTelegramToken] = useState<string>("")
const [telegramChatId, setTelegramChatId] = useState<string>("") const [telegramChatId, setTelegramChatId] = useState<string>("")
const [discordWebhook, setDiscordWebhook] = 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 [notifications, setNotifications] = useState<any[]>([]) const [notifications, setNotifications] = useState<any[]>([])
const [notificationText, setNotificationText] = useState<string>("") const [notificationText, setNotificationText] = useState<string>("")
const changeEmail = async () => { const changeEmail = async () => {
setEmailErrorVisible(false); setEmailErrorVisible(false)
setEmailSuccess(false); setEmailSuccess(false)
setEmailError(""); setEmailError("")
if (!email) { if (!email) {
setEmailError("Email is required"); setEmailError("Email is required")
setEmailErrorVisible(true); setEmailErrorVisible(true)
setTimeout(() => { setTimeout(() => {
setEmailErrorVisible(false); setEmailErrorVisible(false)
setEmailError(""); setEmailError("")
} }, 3000)
, 3000); return
return;
} }
try { try {
await axios.post('/api/auth/edit_email', { await axios.post("/api/auth/edit_email", {
newEmail: email, newEmail: email,
jwtToken: Cookies.get('token') jwtToken: Cookies.get("token"),
}); })
setEmailSuccess(true); setEmailSuccess(true)
setEmail(""); setEmail("")
setTimeout(() => { setTimeout(() => {
setEmailSuccess(false); setEmailSuccess(false)
}, 3000); }, 3000)
} catch (error: any) { } catch (error: any) {
setEmailError(error.response.data.error); setEmailError(error.response.data.error)
setEmailErrorVisible(true); setEmailErrorVisible(true)
setTimeout(() => { setTimeout(() => {
setEmailErrorVisible(false); setEmailErrorVisible(false)
setEmailError(""); setEmailError("")
}, 3000); }, 3000)
} }
} }
const changePassword = async () => { const changePassword = async () => {
try { try {
if (password !== confirmPassword) { if (password !== confirmPassword) {
setPasswordError("Passwords do not match"); setPasswordError("Passwords do not match")
setPasswordErrorVisible(true); setPasswordErrorVisible(true)
setTimeout(() => { setTimeout(() => {
setPasswordErrorVisible(false); setPasswordErrorVisible(false)
setPasswordError(""); setPasswordError("")
}, 3000); }, 3000)
return; return
} }
if (!oldPassword || !password || !confirmPassword) { if (!oldPassword || !password || !confirmPassword) {
setPasswordError("All fields are required"); setPasswordError("All fields are required")
setPasswordErrorVisible(true); setPasswordErrorVisible(true)
setTimeout(() => { setTimeout(() => {
setPasswordErrorVisible(false); setPasswordErrorVisible(false)
setPasswordError(""); setPasswordError("")
}, 3000); }, 3000)
return; return
} }
const response = await axios.post('/api/auth/edit_password', { const response = await axios.post("/api/auth/edit_password", {
oldPassword: oldPassword, oldPassword: oldPassword,
newPassword: password, newPassword: password,
jwtToken: Cookies.get('token') jwtToken: Cookies.get("token"),
}); })
if (response.status === 200) { if (response.status === 200) {
setPasswordSuccess(true); setPasswordSuccess(true)
setPassword(""); setPassword("")
setOldPassword(""); setOldPassword("")
setConfirmPassword(""); setConfirmPassword("")
setTimeout(() => { setTimeout(() => {
setPasswordSuccess(false); setPasswordSuccess(false)
}, 3000); }, 3000)
} }
} catch (error: any) { } catch (error: any) {
setPasswordErrorVisible(true); setPasswordErrorVisible(true)
setPasswordError(error.response.data.error); setPasswordError(error.response.data.error)
setTimeout(() => { setTimeout(() => {
setPasswordErrorVisible(false); setPasswordErrorVisible(false)
setPasswordError(""); setPasswordError("")
}, 3000); }, 3000)
} }
} }
const addNotification = async () => { const addNotification = async () => {
try { try {
const response = await axios.post('/api/notifications/add', { const response = await axios.post("/api/notifications/add", {
type: notificationType, type: notificationType,
smtpHost: smtpHost, smtpHost: smtpHost,
smtpPort: smtpPort, smtpPort: smtpPort,
@@ -176,37 +171,39 @@ export default function Settings() {
smtpTo: smtpTo, smtpTo: smtpTo,
telegramToken: telegramToken, telegramToken: telegramToken,
telegramChatId: telegramChatId, telegramChatId: telegramChatId,
discordWebhook: discordWebhook discordWebhook: discordWebhook,
}); gotifyUrl: gotifyUrl,
getNotifications(); gotifyToken: gotifyToken,
} ntfyUrl: ntfyUrl,
catch (error: any) { ntfyToken: ntfyToken,
alert(error.response.data.error); })
getNotifications()
} catch (error: any) {
alert(error.response.data.error)
} }
} }
const deleteNotification = async (id: number) => { const deleteNotification = async (id: number) => {
try { try {
const response = await axios.post('/api/notifications/delete', { const response = await axios.post("/api/notifications/delete", {
id: id id: id,
}); })
if (response.status === 200) { if (response.status === 200) {
getNotifications() getNotifications()
} }
} catch (error: any) { } catch (error: any) {
alert(error.response.data.error); alert(error.response.data.error)
} }
} }
const getNotifications = async () => { const getNotifications = async () => {
try { try {
const response = await axios.post<NotificationsResponse>('/api/notifications/get', {}); const response = await axios.post<NotificationsResponse>("/api/notifications/get", {})
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
setNotifications(response.data.notifications); setNotifications(response.data.notifications)
} }
} } catch (error: any) {
catch (error: any) { alert(error.response.data.error)
alert(error.response.data.error);
} }
} }
@@ -214,29 +211,28 @@ export default function Settings() {
getNotifications() getNotifications()
}, []) }, [])
const getNotificationText = async () => { const getNotificationText = async () => {
try { try {
const response = await axios.post<NotificationResponse>('/api/settings/get_notification_text', {}); const response = await axios.post<NotificationResponse>("/api/settings/get_notification_text", {})
if (response.status === 200) { if (response.status === 200) {
if (response.data.notification_text) { if (response.data.notification_text) {
setNotificationText(response.data.notification_text); setNotificationText(response.data.notification_text)
} else { } else {
setNotificationText("The application !name (!url) is now !status."); setNotificationText("The application !name (!url) is now !status.")
} }
} }
} catch (error: any) { } catch (error: any) {
alert(error.response.data.error); alert(error.response.data.error)
} }
}; }
const editNotificationText = async () => { const editNotificationText = async () => {
try { try {
const response = await axios.post('/api/settings/notification_text', { const response = await axios.post("/api/settings/notification_text", {
text: notificationText text: notificationText,
}); })
} catch (error: any) { } catch (error: any) {
alert(error.response.data.error); alert(error.response.data.error)
} }
} }
@@ -403,209 +399,324 @@ export default function Settings() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm"> <Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<CardHeader className="bg-muted/10 px-6 py-4 border-b"> <CardHeader className="bg-muted/10 px-6 py-4 border-b">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-primary" /> <div className="bg-muted/20 p-2 rounded-full">
<Bell className="h-5 w-5 text-primary" />
</div>
<h2 className="text-xl font-semibold">Notifications</h2> <h2 className="text-xl font-semibold">Notifications</h2>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pb-6"> <CardContent className="p-6">
<div className="text-sm text-muted-foreground mb-6"> <div className="text-sm text-muted-foreground mb-6">
Set up Notifications to get notified when an application goes offline or online. Set up notifications to get instantly alerted when an application changes status.
</div> </div>
<AlertDialog> <div className="grid gap-4 md:grid-cols-2">
<AlertDialogTrigger asChild> <AlertDialog>
<Button className="w-full"> <AlertDialogTrigger asChild>
Add Notification <Button className="w-full h-11 flex items-center gap-2">
</Button> Add Notification Channel
</AlertDialogTrigger> </Button>
<AlertDialogContent> </AlertDialogTrigger>
<AlertDialogTitle>Add Notification</AlertDialogTitle> <AlertDialogContent>
<AlertDialogDescription> <AlertDialogTitle>Add Notification</AlertDialogTitle>
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}> <AlertDialogDescription>
<SelectTrigger className="w-full"> <Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
<SelectValue placeholder="Notification Type" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="Notification Type" />
<SelectContent> </SelectTrigger>
<SelectItem value="smtp">SMTP</SelectItem> <SelectContent>
<SelectItem value="telegram">Telegram</SelectItem> <SelectItem value="smtp">SMTP</SelectItem>
<SelectItem value="discord">Discord</SelectItem> <SelectItem value="telegram">Telegram</SelectItem>
</SelectContent> <SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
</SelectContent>
{notificationType === "smtp" && ( {notificationType === "smtp" && (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="smtpHost">SMTP Host</Label> <Label htmlFor="smtpHost">SMTP Host</Label>
<Input <Input
type="text" type="text"
id="smtpHost" id="smtpHost"
placeholder="smtp.example.com" placeholder="smtp.example.com"
onChange={(e) => setSmtpHost(e.target.value)} onChange={(e) => setSmtpHost(e.target.value)}
/> />
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPort">SMTP Port</Label>
<Input
type="number"
id="smtpPort"
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">
Secure Connection (TLS/SSL)
</Label>
</div>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpUser">SMTP Username</Label>
<Input
type="text"
id="smtpUser"
placeholder="user@example.com"
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPass">SMTP Password</Label>
<Input
type="password"
id="smtpPass"
placeholder="••••••••"
onChange={(e) => setSmtpPassword(e.target.value)}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpFrom">From Address</Label>
<Input
type="email"
id="smtpFrom"
placeholder="noreply@example.com"
onChange={(e) => setSmtpFrom(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpTo">To Address</Label>
<Input
type="email"
id="smtpTo"
placeholder="admin@example.com"
onChange={(e) => setSmtpTo(e.target.value)}
/>
</div>
</div>
</div>
</div> </div>
<div className="space-y-1.5"> )}
<Label htmlFor="smtpPort">SMTP Port</Label>
<Input {notificationType === "telegram" && (
type="number" <div className="mt-4 space-y-2">
id="smtpPort" <div className="grid w-full items-center gap-1.5">
placeholder="587" <Label htmlFor="telegramToken">Bot Token</Label>
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">
Secure Connection (TLS/SSL)
</Label>
</div>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpUser">SMTP Username</Label>
<Input
type="text"
id="smtpUser"
placeholder="user@example.com"
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPass">SMTP Password</Label>
<Input
type="password"
id="smtpPass"
placeholder="••••••••"
onChange={(e) => setSmtpPassword(e.target.value)}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpFrom">From Address</Label>
<Input <Input
type="email" type="text"
id="smtpFrom" id="telegramToken"
placeholder="noreply@example.com" placeholder=""
onChange={(e) => setSmtpFrom(e.target.value)} onChange={(e) => setTelegramToken(e.target.value)}
/> />
</div> </div>
<div className="grid w-full items-center gap-1.5">
<div className="space-y-1.5"> <Label htmlFor="telegramChatId">Chat ID</Label>
<Label htmlFor="smtpTo">To Address</Label>
<Input <Input
type="email" type="text"
id="smtpTo" id="telegramChatId"
placeholder="admin@example.com" placeholder=""
onChange={(e) => setSmtpTo(e.target.value)} onChange={(e) => setTelegramChatId(e.target.value)}
/> />
</div> </div>
</div> </div>
</div> )}
</div>
)}
{notificationType === "telegram" && ( {notificationType === "discord" && (
<div className="mt-4 space-y-2"> <div className="mt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramToken">Bot Token</Label> <Label htmlFor="discordWebhook">Webhook URL</Label>
<Input type="text" id="telegramToken" placeholder="" onChange={(e) => setTelegramToken(e.target.value)} /> <Input
type="text"
id="discordWebhook"
placeholder=""
onChange={(e) => setDiscordWebhook(e.target.value)}
/>
</div>
</div> </div>
<div className="grid w-full items-center gap-1.5"> )}
<Label htmlFor="telegramChatId">Chat ID</Label>
<Input type="text" id="telegramChatId" placeholder="" onChange={(e) => setTelegramChatId(e.target.value)} /> {notificationType === "gotify" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="gotifyUrl">Gotify URL</Label>
<Input
type="text"
id="gotifyUrl"
placeholder=""
onChange={(e) => setGotifyUrl(e.target.value)}
/>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="gotifyToken">Gotify Token</Label>
<Input
type="text"
id="gotifyToken"
placeholder=""
onChange={(e) => setGotifyToken(e.target.value)}
/>
</div>
</div>
</div> </div>
</div> )}
)}
{notificationType === "discord" && ( {notificationType === "ntfy" && (
<div className="mt-4"> <div className="mt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="discordWebhook">Webhook URL</Label> <Label htmlFor="ntfyUrl">Ntfy URL</Label>
<Input type="text" id="discordWebhook" placeholder="" onChange={(e) => setDiscordWebhook(e.target.value)} /> <Input
type="text"
id="ntfyUrl"
placeholder=""
onChange={(e) => setNtfyUrl(e.target.value)}
/>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="ntfyToken">Ntfy Token</Label>
<Input
type="text"
id="ntfyToken"
placeholder=""
onChange={(e) => setNtfyToken(e.target.value)}
/>
</div>
</div>
</div> </div>
</div> )}
)} </Select>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>Add</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Select> <AlertDialog>
</AlertDialogDescription> <AlertDialogTrigger asChild>
<AlertDialogFooter> <Button className="w-full h-11" variant="outline">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>
Add
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="pt-4 pb-2">
<Button className="w-full" variant="secondary">
Customize Notification Text Customize Notification Text
</Button> </Button>
</div> </AlertDialogTrigger>
</AlertDialogTrigger> <AlertDialogContent>
<AlertDialogContent> <AlertDialogTitle>Customize Notification Text</AlertDialogTitle>
<AlertDialogTitle>Customize Notification Text</AlertDialogTitle> <AlertDialogDescription>
<AlertDialogDescription> <div className="space-y-4">
<div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="text">Notification Text</Label> <Label htmlFor="text">Notification Text</Label>
<Textarea id="text" placeholder="Type here..." value={notificationText} onChange={(e) => setNotificationText(e.target.value)} rows={4} /> <Textarea
id="text"
placeholder="Type here..."
value={notificationText}
onChange={(e) => setNotificationText(e.target.value)}
rows={4}
/>
</div> </div>
</div>
<div className="pt-4 text-sm text-muted-foreground">
You can use the following placeholders in the text:
<ul className="list-disc list-inside space-y-1 pt-2">
<li><strong>!name</strong> - Application name</li>
<li><strong>!url</strong> - Application URL</li>
<li><strong>!status</strong> - Application status (online/offline)</li>
</ul>
</div>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={editNotificationText}>
Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="mt-6 space-y-4">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className="flex items-center justify-between p-4 bg-muted/10 rounded-lg border"
>
<div className="space-y-1">
<h3 className="font-medium capitalize">{notification.type}</h3>
</div> </div>
<Button <div className="pt-4 text-sm text-muted-foreground">
variant="destructive" You can use the following placeholders in the text:
size="sm" <ul className="list-disc list-inside space-y-1 pt-2">
onClick={() => deleteNotification(notification.id)} <li>
<strong>!name</strong> - Application name
</li>
<li>
<strong>!url</strong> - Application URL
</li>
<li>
<strong>!status</strong> - Application status (online/offline)
</li>
</ul>
</div>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={editNotificationText}>Save</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium mb-4">Active Notification Channels</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"
> >
Delete <div className="flex items-center gap-3">
</Button> {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>
)}
<div className="space-y-1">
<h3 className="font-medium capitalize">{notification.type}</h3>
<p className="text-xs text-muted-foreground">
{notification.type === "smtp" && "Email notifications"}
{notification.type === "telegram" && "Telegram bot alerts"}
{notification.type === "discord" && "Discord webhook alerts"}
{notification.type === "gotify" && "Gotify notifications"}
{notification.type === "ntfy" && "Ntfy notifications"}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="hover:bg-muted/20"
onClick={() => deleteNotification(notification.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
</Button>
</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">No notifications configured</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Add a notification channel to get alerted when your applications change status.
</p>
</div> </div>
)) )}
) : ( </div>
<div className="text-center text-muted-foreground py-6">
No notifications configured
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

52
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.6", "version": "0.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.6", "version": "0.0.7",
"dependencies": { "dependencies": {
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"@prisma/extension-accelerate": "^1.3.0", "@prisma/extension-accelerate": "^1.3.0",
@@ -17,6 +17,7 @@
"@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.4", "@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "^1.1.3",
@@ -1690,6 +1691,53 @@
} }
} }
}, },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz",
"integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.6", "version": "0.0.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.4", "@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "^1.1.3",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "notification" ADD COLUMN "gotifyToken" TEXT,
ADD COLUMN "gotifyUrl" TEXT,
ADD COLUMN "ntfyToken" TEXT,
ADD COLUMN "ntfyUrl" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "server" ADD COLUMN "icon" TEXT;

View File

@@ -37,6 +37,7 @@ model server {
host Boolean @default(false) host Boolean @default(false)
hostServer Int? hostServer Int?
name String name String
icon String?
os String? os String?
ip String? ip String?
url String? url String?
@@ -72,4 +73,8 @@ model notification {
telegramChatId String? telegramChatId String?
telegramToken String? telegramToken String?
discordWebhook String? discordWebhook String?
gotifyUrl String?
gotifyToken String?
ntfyUrl String?
ntfyToken String?
} }