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
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
@@ -78,6 +81,11 @@ services:
POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
volumes:
postgres_data:

View File

@@ -1,9 +1,11 @@
package main
import (
"bytes"
"context"
"crypto/x509"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
@@ -40,6 +42,10 @@ type Notification struct {
TelegramChatID sql.NullString
TelegramToken sql.NullString
DiscordWebhook sql.NullString
GotifyUrl sql.NullString
GotifyToken sql.NullString
NtfyUrl sql.NullString
NtfyToken sql.NullString
}
var (
@@ -134,7 +140,7 @@ func isIPAddress(host string) bool {
func loadNotifications(db *sql.DB) ([]Notification, error) {
rows, err := db.Query(
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
"telegramChatId", "telegramToken", "discordWebhook"
"telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken"
FROM notification
WHERE enabled = true`,
)
@@ -149,7 +155,7 @@ func loadNotifications(db *sql.DB) ([]Notification, error) {
if err := rows.Scan(
&n.ID, &n.Enabled, &n.Type,
&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 {
fmt.Printf("Error scanning notification: %v\n", err)
continue
@@ -200,7 +206,7 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
var notificationTemplate string
err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate)
if err != nil || notificationTemplate == "" {
notificationTemplate = "The application '!name' (!url) went !status!"
notificationTemplate = "The application !name (!url) went !status!"
}
for _, app := range apps {
@@ -314,6 +320,14 @@ func sendNotifications(message string) {
if n.DiscordWebhook.Valid {
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()
}
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) {
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();
@@ -12,7 +24,8 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json({
serverCount,
serverCountNoVMs,
serverCountOnlyVMs,
applicationCount,
onlineApplicationsCount
});

View File

@@ -30,6 +30,8 @@ interface Server {
id: number;
name: string;
ip: string;
host: boolean;
hostServer: number | null;
}
interface Application {
@@ -43,11 +45,15 @@ const NODE_WIDTH = 220;
const NODE_HEIGHT = 60;
const APP_NODE_WIDTH = 160;
const APP_NODE_HEIGHT = 40;
const HORIZONTAL_SPACING = 280;
const VERTICAL_SPACING = 60;
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 {
@@ -60,30 +66,13 @@ export async function GET() {
}) as Promise<Application[]>,
]);
// Root Node
const rootNode: Node = {
id: "root",
type: "infrastructure",
data: { label: "My Infrastructure" },
position: { x: 0, 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",
},
};
// Server Nodes
const serverNodes: Node[] = servers.map((server, index) => {
// Level 2: Physical Servers
const serverNodes: Node[] = servers
.filter(server => !server.hostServer)
.map((server, index, filteredServers) => {
const xPos =
index * HORIZONTAL_SPACING -
((servers.length - 1) * HORIZONTAL_SPACING) / 2;
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
return {
id: `server-${server.id}`,
@@ -108,26 +97,107 @@ export async function GET() {
};
});
// Application Nodes
const appNodes: Node[] = [];
// Level 3: Services and VMs
const serviceNodes: Node[] = [];
const vmNodes: Node[] = [];
servers.forEach((server) => {
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
const serverX = serverNode?.position.x || 0;
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
if (serverNode) {
const serverX = serverNode.position.x;
// Services (left column)
applications
.filter((app) => app.serverId === server.id)
.filter(app => app.serverId === server.id)
.forEach((app, appIndex) => {
appNodes.push({
id: `app-${app.id}`,
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: serverX + xOffset,
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
x: vmX + VM_APP_SPACING,
y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
},
style: {
background: "#f5f5f5",
@@ -145,38 +215,14 @@ export async function GET() {
});
});
// Connections
const connections: Edge[] = [
...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];
// Calculate dimensions for root node positioning
const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
allNodes.forEach((node) => {
tempNodes.forEach((node) => {
const width = parseInt(node.style.width?.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);
});
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: minX - CONTAINER_PADDING,
y: minY - CONTAINER_PADDING
x: newMinX - CONTAINER_PADDING,
y: newMinY - CONTAINER_PADDING
},
style: {
width: maxX - minX + 2 * CONTAINER_PADDING,
height: maxY - minY + 2 * CONTAINER_PADDING,
width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
background: 'transparent',
border: '2px dashed #e2e8f0',
borderRadius: '8px',
@@ -207,6 +283,116 @@ export async function GET() {
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,

View File

@@ -13,12 +13,16 @@ interface AddRequest {
telegramToken?: string;
telegramChatId?: string;
discordWebhook?: string;
gotifyUrl?: string;
gotifyToken?: string;
ntfyUrl?: string;
ntfyToken?: string;
}
export async function POST(request: NextRequest) {
try {
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({
data: {
@@ -33,6 +37,10 @@ export async function POST(request: NextRequest) {
telegramChatId: telegramChatId,
telegramToken: telegramToken,
discordWebhook: discordWebhook,
gotifyUrl: gotifyUrl,
gotifyToken: gotifyToken,
ntfyUrl: ntfyUrl,
ntfyToken: ntfyToken,
}
});

View File

@@ -5,6 +5,7 @@ interface AddRequest {
host: boolean;
hostServer: number;
name: string;
icon: string;
os: string;
ip: string;
url: string;
@@ -18,13 +19,14 @@ interface AddRequest {
export async function POST(request: NextRequest) {
try {
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({
data: {
host,
hostServer,
name,
icon,
os,
ip,
url,

View File

@@ -6,6 +6,7 @@ interface EditRequest {
hostServer: number;
id: number;
name: string;
icon: string;
os: string;
ip: string;
url: string;
@@ -18,19 +19,27 @@ interface EditRequest {
export async function PUT(request: NextRequest) {
try {
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 } });
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,
hostServer: newHostServer,
name,
icon,
os,
ip,
url,

View File

@@ -20,17 +20,29 @@ export async function POST(request: NextRequest) {
});
const hostsWithVms = await Promise.all(
hosts.map(async (host) => ({
...host,
hostedVMs: await prisma.server.findMany({
hosts.map(async (host) => {
const vms = await prisma.server.findMany({
where: { hostServer: host.id },
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({
where: { hostServer: null }
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
});
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({
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) {
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 { 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'],
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
threshold: 0.3,
includeScore: true,
};
const fuse = new Fuse(servers, fuseOptions);
const fuse = new Fuse(serversWithType, fuseOptions);
const searchResults = fuse.search(searchterm);

View File

@@ -19,20 +19,23 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Button } from "@/components/ui/button"
interface StatsResponse {
serverCount: number
serverCountNoVMs: number
serverCountOnlyVMs: number
applicationCount: number
onlineApplicationsCount: number
}
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 [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
const getStats = async () => {
try {
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)
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) {
@@ -69,21 +72,51 @@ export default function Dashboard() {
<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">
<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">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Servers</CardTitle>
<Server className="h-6 w-6 text-rose-500" />
<div>
<CardTitle className="text-2xl font-semibold">Servers</CardTitle>
<CardDescription className="mt-1">Physical and virtual servers overview</CardDescription>
</div>
<Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
</div>
<CardDescription>Manage your server infrastructure</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
<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">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/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/servers">View all servers</Link>
<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>
@@ -152,7 +185,7 @@ export default function Dashboard() {
<CardDescription>Manage network configuration</CardDescription>
</CardHeader>
<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>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">

View File

@@ -479,7 +479,7 @@ export default function Dashboard() {
</CardDescription>
</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 flex-col space-y-2 flex-grow">
<Button
@@ -490,7 +490,7 @@ export default function Dashboard() {
}
>
<Link className="h-4 w-4" />
Open Public URL
Public URL
</Button>
{app.localURL && (
<Button
@@ -501,7 +501,7 @@ export default function Dashboard() {
}
>
<Home className="h-4 w-4" />
Open Local URL
Local URL
</Button>
)}
</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 {
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";
} 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 { 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 } from "lucide-react";
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2 } from "lucide-react"
import {
AlertDialog,
@@ -39,19 +31,19 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Textarea } from "@/components/ui/textarea"
interface NotificationsResponse {
notifications: any[];
notifications: any[]
}
interface NotificationResponse {
notification_text?: string;
notification_text?: string
}
export default function Settings() {
const { theme, setTheme } = useTheme();
const { theme, setTheme } = useTheme()
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
@@ -77,95 +69,98 @@ export default function Settings() {
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 [notifications, setNotifications] = useState<any[]>([])
const [notificationText, setNotificationText] = useState<string>("")
const changeEmail = async () => {
setEmailErrorVisible(false);
setEmailSuccess(false);
setEmailError("");
setEmailErrorVisible(false)
setEmailSuccess(false)
setEmailError("")
if (!email) {
setEmailError("Email is required");
setEmailErrorVisible(true);
setEmailError("Email is required")
setEmailErrorVisible(true)
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}
, 3000);
return;
setEmailErrorVisible(false)
setEmailError("")
}, 3000)
return
}
try {
await axios.post('/api/auth/edit_email', {
await axios.post("/api/auth/edit_email", {
newEmail: email,
jwtToken: Cookies.get('token')
});
setEmailSuccess(true);
setEmail("");
jwtToken: Cookies.get("token"),
})
setEmailSuccess(true)
setEmail("")
setTimeout(() => {
setEmailSuccess(false);
}, 3000);
setEmailSuccess(false)
}, 3000)
} catch (error: any) {
setEmailError(error.response.data.error);
setEmailErrorVisible(true);
setEmailError(error.response.data.error)
setEmailErrorVisible(true)
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}, 3000);
setEmailErrorVisible(false)
setEmailError("")
}, 3000)
}
}
const changePassword = async () => {
try {
if (password !== confirmPassword) {
setPasswordError("Passwords do not match");
setPasswordErrorVisible(true);
setPasswordError("Passwords do not match")
setPasswordErrorVisible(true)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
return
}
if (!oldPassword || !password || !confirmPassword) {
setPasswordError("All fields are required");
setPasswordErrorVisible(true);
setPasswordError("All fields are required")
setPasswordErrorVisible(true)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
return
}
const response = await axios.post('/api/auth/edit_password', {
const response = await axios.post("/api/auth/edit_password", {
oldPassword: oldPassword,
newPassword: password,
jwtToken: Cookies.get('token')
});
jwtToken: Cookies.get("token"),
})
if (response.status === 200) {
setPasswordSuccess(true);
setPassword("");
setOldPassword("");
setConfirmPassword("");
setPasswordSuccess(true)
setPassword("")
setOldPassword("")
setConfirmPassword("")
setTimeout(() => {
setPasswordSuccess(false);
}, 3000);
setPasswordSuccess(false)
}, 3000)
}
} catch (error: any) {
setPasswordErrorVisible(true);
setPasswordError(error.response.data.error);
setPasswordErrorVisible(true)
setPasswordError(error.response.data.error)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
}
}
const addNotification = async () => {
try {
const response = await axios.post('/api/notifications/add', {
const response = await axios.post("/api/notifications/add", {
type: notificationType,
smtpHost: smtpHost,
smtpPort: smtpPort,
@@ -176,37 +171,39 @@ export default function Settings() {
smtpTo: smtpTo,
telegramToken: telegramToken,
telegramChatId: telegramChatId,
discordWebhook: discordWebhook
});
getNotifications();
}
catch (error: any) {
alert(error.response.data.error);
discordWebhook: discordWebhook,
gotifyUrl: gotifyUrl,
gotifyToken: gotifyToken,
ntfyUrl: ntfyUrl,
ntfyToken: ntfyToken,
})
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
});
const response = await axios.post("/api/notifications/delete", {
id: id,
})
if (response.status === 200) {
getNotifications()
}
} catch (error: any) {
alert(error.response.data.error);
alert(error.response.data.error)
}
}
const getNotifications = async () => {
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) {
setNotifications(response.data.notifications);
setNotifications(response.data.notifications)
}
}
catch (error: any) {
alert(error.response.data.error);
} catch (error: any) {
alert(error.response.data.error)
}
}
@@ -214,29 +211,28 @@ export default function Settings() {
getNotifications()
}, [])
const getNotificationText = async () => {
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.data.notification_text) {
setNotificationText(response.data.notification_text);
setNotificationText(response.data.notification_text)
} else {
setNotificationText("The application !name (!url) is now !status.");
setNotificationText("The application !name (!url) is now !status.")
}
}
} catch (error: any) {
alert(error.response.data.error);
alert(error.response.data.error)
}
}
};
const editNotificationText = async () => {
try {
const response = await axios.post('/api/settings/notification_text', {
text: notificationText
});
const response = await axios.post("/api/settings/notification_text", {
text: notificationText,
})
} catch (error: any) {
alert(error.response.data.error);
alert(error.response.data.error)
}
}
@@ -403,23 +399,25 @@ export default function Settings() {
</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">
<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">Notifications</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<CardContent className="p-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 className="grid gap-4 md:grid-cols-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full">
Add Notification
<Button className="w-full h-11 flex items-center gap-2">
Add Notification Channel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -433,6 +431,8 @@ export default function Settings() {
<SelectItem value="smtp">SMTP</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
</SelectContent>
{notificationType === "smtp" && (
@@ -459,10 +459,7 @@ export default function Settings() {
</div>
<div className="flex items-center space-x-2 pt-2 pb-4">
<Checkbox
id="smtpSecure"
onCheckedChange={(checked: any) => setSmtpSecure(checked)}
/>
<Checkbox id="smtpSecure" onCheckedChange={(checked: any) => setSmtpSecure(checked)} />
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
Secure Connection (TLS/SSL)
</Label>
@@ -518,11 +515,21 @@ export default function Settings() {
<div className="mt-4 space-y-2">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramToken">Bot Token</Label>
<Input type="text" id="telegramToken" placeholder="" onChange={(e) => setTelegramToken(e.target.value)} />
<Input
type="text"
id="telegramToken"
placeholder=""
onChange={(e) => setTelegramToken(e.target.value)}
/>
</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)} />
<Input
type="text"
id="telegramChatId"
placeholder=""
onChange={(e) => setTelegramChatId(e.target.value)}
/>
</div>
</div>
)}
@@ -531,29 +538,75 @@ export default function Settings() {
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="discordWebhook">Webhook URL</Label>
<Input type="text" id="discordWebhook" placeholder="" onChange={(e) => setDiscordWebhook(e.target.value)} />
<Input
type="text"
id="discordWebhook"
placeholder=""
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 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>
)}
{notificationType === "ntfy" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="ntfyUrl">Ntfy URL</Label>
<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>
)}
</Select>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>
Add
</AlertDialogAction>
<AlertDialogAction onClick={addNotification}>Add</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="pt-4 pb-2">
<Button className="w-full" variant="secondary">
<Button className="w-full h-11" variant="outline">
Customize Notification Text
</Button>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Customize Notification Text</AlertDialogTitle>
@@ -561,52 +614,110 @@ export default function Settings() {
<div className="space-y-4">
<div className="space-y-1.5">
<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 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>
<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>
<AlertDialogAction onClick={editNotificationText}>Save</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-6 space-y-4">
<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 bg-muted/10 rounded-lg border"
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>
)}
<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="destructive"
variant="ghost"
size="sm"
className="hover:bg-muted/20"
onClick={() => deleteNotification(notification.id)}
>
Delete
<Trash2 className="h-4 w-4 mr-1" />
Remove
</Button>
</div>
))
) : (
<div className="text-center text-muted-foreground py-6">
No notifications configured
<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>
</CardContent>
</Card>
</div>

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",
"version": "0.0.6",
"version": "0.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "corecontrol",
"version": "0.0.6",
"version": "0.0.7",
"dependencies": {
"@prisma/client": "^6.6.0",
"@prisma/extension-accelerate": "^1.3.0",
@@ -17,6 +17,7 @@
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@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-select": "^2.1.7",
"@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": {
"version": "1.1.3",
"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",
"version": "0.0.6",
"version": "0.0.7",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@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-select": "^2.1.7",
"@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)
hostServer Int?
name String
icon String?
os String?
ip String?
url String?
@@ -72,4 +73,8 @@ model notification {
telegramChatId String?
telegramToken String?
discordWebhook String?
gotifyUrl String?
gotifyToken String?
ntfyUrl String?
ntfyToken String?
}