78 Commits

Author SHA1 Message Date
headlessdev
676354f53c v0.0.4 dev->main
v0.0.4
2025-04-16 14:17:20 +02:00
headlessdev
288bb4cc9e GITBOOK-1: No subject 2025-04-16 12:09:35 +00:00
headlessdev
7b86de86e3 GitBook: No commit message 2025-04-16 11:58:29 +00:00
headlessdev
3e43c01e5a Images fixes 2025-04-15 21:18:39 +02:00
headlessdev
43c09e7a09 Readme.md Fix 2025-04-15 21:17:08 +02:00
headlessdev
bdce02ac2b Update login section header style in README 2025-04-15 21:16:37 +02:00
headlessdev
eee74c9df9 Update Uptime Page screenshot 2025-04-15 16:34:47 +02:00
headlessdev
f21dae5646 Remove application count display from Uptime component 2025-04-15 16:33:45 +02:00
headlessdev
c8a21797c9 Uptime Pagination Fix 2025-04-15 16:33:12 +02:00
headlessdev
32d99a09a6 Update screenshots in README for improved visual representation 2025-04-15 16:29:28 +02:00
headlessdev
7660f8bb5c Enhance header styling and increase font size for better visibility across multiple dashboard components 2025-04-15 16:25:01 +02:00
headlessdev
8ff112e86e Update padding in CardContent for User and Theme Settings sections 2025-04-15 16:19:18 +02:00
headlessdev
7eafdef288 Settings Page Style Update 2025-04-15 16:18:35 +02:00
headlessdev
724a634985 Login page Styling Update 2025-04-15 16:14:10 +02:00
headlessdev
1e8682646d Dashboard Styling Update 2025-04-15 16:11:31 +02:00
headlessdev
39ba85dcf4 Clarify JWT_SECRET placeholder in configuration files 2025-04-15 16:04:06 +02:00
headlessdev
a055d72246 Fix formatting of default login credentials in README.md 2025-04-15 15:11:55 +02:00
headlessdev
aef0d6f812 Add default login credentials to README.md 2025-04-15 15:11:03 +02:00
headlessdev
b6e17e5d39 Updated Screenshots in Readme.md 2025-04-15 15:08:42 +02:00
headlessdev
879ce1e6f1 Language issue 2025-04-15 15:05:01 +02:00
headlessdev
c02b057f8e Readme.md Update 2025-04-15 15:04:07 +02:00
headlessdev
b0d7271d34 Enhance login page card width for improved responsiveness 2025-04-15 15:01:11 +02:00
headlessdev
6ca1b2ac97 Refactor login page header for improved layout 2025-04-15 14:59:23 +02:00
headlessdev
8059b0e541 Read.md Update & Cover Image 2025-04-15 14:51:57 +02:00
headlessdev
9c9b556124 Dashboard Update 2025-04-15 14:49:07 +02:00
headlessdev
871ab74576 Update online status calculation to check if failure rate is below 50% 2025-04-15 14:30:29 +02:00
headlessdev
a3b47bd314 responsive Uptime Page 2025-04-15 14:21:51 +02:00
headlessdev
1088806921 Delete Uptime History from deleted Applications 2025-04-15 14:10:16 +02:00
headlessdev
a320c04b92 Update Uptime Pagination 2025-04-15 14:09:36 +02:00
headlessdev
e34407539a Remove comment 2025-04-15 13:59:02 +02:00
headlessdev
f340543625 Uptime Pagination 2025-04-15 13:58:53 +02:00
headlessdev
4aaefe4c55 Remove unnecessary Code comments 2025-04-15 13:53:32 +02:00
headlessdev
12abe9c0d7 Uptime.tsx Type Error Fix 2025-04-15 13:52:46 +02:00
headlessdev
2f6957a45d Uptime functionality 2025-04-15 13:46:25 +02:00
headlessdev
ed46598c27 Uptime History Page 2025-04-15 12:32:49 +02:00
headlessdev
1a80c61c34 Uptime Page 2025-04-15 12:22:15 +02:00
headlessdev
52f3c0432f Uptime SIdebar Fix 2025-04-15 12:20:03 +02:00
headlessdev
faad5198a6 Activity Tab Sidebar 2025-04-15 12:17:14 +02:00
headlessdev
3f1f7b730e uptime_history for agent 2025-04-15 11:37:30 +02:00
headlessdev
19ef051e1e Docker Compose Update 2025-04-14 23:36:33 +02:00
headlessdev
75d1bd59f4 Readme.md Roadmap Update 2025-04-14 22:31:41 +02:00
headlessdev
7da6501ca7 Uptime History DB Model 2025-04-14 22:16:22 +02:00
headlessdev
d779355c4c Prevent deletions of servers with associated applications 2025-04-14 21:54:25 +02:00
headlessdev
e844712c29 Network Chart Styling Update 2025-04-14 21:48:09 +02:00
headlessdev
36beeb8a2c Pagination Button Improvements 2025-04-14 21:36:15 +02:00
headlessdev
e51600016b Pagination padding bottom 2025-04-14 21:28:09 +02:00
headlessdev
8fc4fea687 Servers ITEMS_PER_PAGE 2025-04-14 21:26:12 +02:00
headlessdev
0a8ea98dae Application Description Improvement 2025-04-14 21:12:12 +02:00
headlessdev
7c86483d48 Generate icon url tooltip 2025-04-14 21:10:21 +02:00
headlessdev
ca31a0b6b3 Login Page Update 2025-04-14 20:59:59 +02:00
headlessdev
6661b1e711 Change Email & password Function 2025-04-14 20:59:41 +02:00
headlessdev
5413dbf948 Edit Password API Route 2025-04-14 20:20:42 +02:00
headlessdev
49fec67996 Edit Email API Route 2025-04-14 20:15:44 +02:00
headlessdev
a1d9839bcc Settings accordion 2025-04-14 20:02:28 +02:00
headlessdev
246f6b594c DB managed user 2025-04-14 19:44:16 +02:00
headlessdev
7549d8c8c0 Readme.md: selfh.st shout-out 2025-04-14 18:54:31 +02:00
headlessdev
fdb8b5073f Version Update 2025-04-14 18:53:34 +02:00
headlessdev
07e7a60163 Agent .dockerignore Update 2025-04-14 18:51:47 +02:00
headlessdev
460c9103f7 v0.0.3 dev->main
v0.0.3
2025-04-14 17:21:20 +02:00
headlessdev
14574e370c Version Update 2025-04-14 17:20:25 +02:00
headlessdev
b915e0066d Hotfix 2025-04-14 17:19:13 +02:00
headlessdev
489c73ef77 Readme.md Buymeacoffee widget 2025-04-14 16:06:56 +02:00
headlessdev
0a41d27701 Fix Readme.md 2025-04-14 15:57:52 +02:00
headlessdev
ebefb45fe6 v0.0.2 dev->main
v0.0.2
2025-04-14 15:47:48 +02:00
headlessdev
5d352c2d1f Auto generate Icon URL 2025-04-14 15:46:28 +02:00
headlessdev
75ca05454d Action Buttons Layout improvements 2025-04-14 15:35:32 +02:00
headlessdev
e631d39b75 Automatically show the correct version in the sidebar 2025-04-14 14:55:16 +02:00
headlessdev
14f7c7fb22 Server search includes more db values 2025-04-14 14:52:03 +02:00
headlessdev
8589ccb35f Server Search Function 2025-04-14 14:50:48 +02:00
headlessdev
1373c5b92e Servers Search API 2025-04-14 14:48:40 +02:00
headlessdev
06b422bfe9 Applications Search Function 2025-04-14 14:47:57 +02:00
headlessdev
fe9fd02b9a Applications Search API 2025-04-14 14:24:03 +02:00
headlessdev
a2d4202a24 Version fix 2025-04-14 14:12:26 +02:00
headlessdev
3bdadab7c6 Edit APplications Function & Edit Application API Fixes 2025-04-14 13:59:36 +02:00
headlessdev
130e282cd6 Edit Application API Route 2025-04-14 12:46:05 +02:00
headlessdev
7023723a16 Fix for database deployment error 2025-04-14 12:27:49 +02:00
headlessdev
e41fa3a694 Star History Readme.md 2025-04-13 22:59:23 +02:00
headlessdev
b4e2d9ee9c Readme.md Images 2025-04-13 21:45:16 +02:00
37 changed files with 3241 additions and 785 deletions

View File

@@ -4,32 +4,37 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
COPY ./prisma ./prisma COPY ./prisma ./prisma
RUN npm install RUN npm install
RUN npx prisma generate RUN npx prisma generate
COPY . . COPY . .
RUN npm run build RUN npm run build
# Production Stage
FROM node:20-alpine AS production FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
# Install production dependencies INCLUDING prisma
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --production RUN npm install --production --ignore-scripts
# Copy needed Prisma files
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
COPY --from=builder /app/prisma ./prisma
# Copy built application
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js* ./ COPY --from=builder /app/next.config.js* ./
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "start"]
# Run migrations first, then start app
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]

View File

@@ -6,6 +6,8 @@
The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance. The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
<a href="https://buymeacoffee.com/corecontrol" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
## Features ## Features
- Dashboard: A clear screen with all the important information about your servers (WIP) - Dashboard: A clear screen with all the important information about your servers (WIP)
@@ -13,11 +15,32 @@ The only dashboard you'll ever need to manage your entire server infrastructure.
- Applications: Add all your self-hosted services to a clear list and track their up and down time - Applications: Add all your self-hosted services to a clear list and track their up and down time
- Networks: Generate visually stunning network flowcharts with ease. - Networks: Generate visually stunning network flowcharts with ease.
## Screenshots
Login Page:
![Login Page](https://i.ibb.co/DfS7BJdX/image.png)
Dashboard Page:
![Dashboard Page](https://i.ibb.co/m5xMXz73/image.png)
Servers Page:
![Servers Page](https://i.ibb.co/QFrFRp1B/image.png)
Applications Page:
![Applications Page](https://i.ibb.co/1JK3pFYG/image.png)
Uptime Page:
![Uptime Page](https://i.ibb.co/99LTnZ14/image.png)
Network Page:
![Network Page](https://i.ibb.co/1Y6ypKHk/image.png)
Settings Page:
![Settings Page](https://i.ibb.co/mrdjqy7f/image.png)
## Roadmap ## Roadmap
- [ ] Edit Applications, Applications searchbar - [X] Edit Applications, Applications searchbar
- [ ] Customizable Dashboard - [X] Uptime History
- [ ] Notifications - [ ] Notifications
- [ ] Uptime History
- [ ] Simple Server Monitoring - [ ] Simple Server Monitoring
- [ ] Improved Network Flowchart with custom elements (like Network switches) - [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more) - [ ] Advanced Settings (Disable Uptime Tracking & more)
@@ -32,11 +55,8 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
LOGIN_EMAIL: "mail@example.com" JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
LOGIN_PASSWORD: "SecretPassword" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
depends_on: depends_on:
- db - db
- agent - agent
@@ -44,7 +64,7 @@ services:
agent: agent:
image: haedlessdev/corecontrol-agent:latest image: haedlessdev/corecontrol-agent:latest
environment: environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db: db:
image: postgres:17 image: postgres:17
@@ -60,6 +80,10 @@ volumes:
postgres_data: postgres_data:
``` ```
#### Default Login
__E-Mail:__ admin@example.com\
__Password:__ admin
## Tech Stack & Credits ## Tech Stack & Credits
The application is build with: The application is build with:
@@ -69,8 +93,13 @@ The application is build with:
- PostgreSQL with [Prisma ORM](https://www.prisma.io/) - PostgreSQL with [Prisma ORM](https://www.prisma.io/)
- Icons by [Lucide](https://lucide.dev/) - Icons by [Lucide](https://lucide.dev/)
- Flowcharts by [React Flow](https://reactflow.dev/) - Flowcharts by [React Flow](https://reactflow.dev/)
- Application icons by [selfh.st/icons](selfh.st/icons)
- and a lot of love ❤️ - and a lot of love ❤️
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=crocofied/CoreControl&type=Date)](https://www.star-history.com/#crocofied/CoreControl&Date)
## License ## License
Licensed under the [MIT License](https://github.com/crocofied/CoreControl/blob/main/LICENSE). Licensed under the [MIT License](https://github.com/crocofied/CoreControl/blob/main/LICENSE).

View File

@@ -1 +1 @@
.env.local .env

View File

@@ -34,19 +34,48 @@ func main() {
} }
defer db.Close() defer db.Close()
ticker := time.NewTicker(5 * time.Second) go func() {
deletionTicker := time.NewTicker(1 * time.Hour)
defer deletionTicker.Stop()
for range deletionTicker.C {
if err := deleteOldEntries(db); err != nil {
fmt.Printf("Error deleting old entries: %v\n", err)
}
}
}()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() defer ticker.Stop()
client := &http.Client{ client := &http.Client{
Timeout: 4 * time.Second, Timeout: 4 * time.Second,
} }
for range ticker.C { for now := range ticker.C {
if now.Second()%10 != 0 {
continue
}
apps := getApplications(db) apps := getApplications(db)
checkAndUpdateStatus(db, client, apps) checkAndUpdateStatus(db, client, apps)
} }
} }
func deleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := db.ExecContext(ctx,
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
return nil
}
func getApplications(db *sql.DB) []Application { func getApplications(db *sql.DB) []Application {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, "publicURL", online SELECT id, "publicURL", online
@@ -90,12 +119,21 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
} }
_, err = db.ExecContext(ctx, _, err = db.ExecContext(ctx,
"UPDATE application SET online = $1 WHERE id = $2", `UPDATE application SET online = $1 WHERE id = $2`,
isOnline, isOnline,
app.ID, app.ID,
) )
if err != nil { if err != nil {
fmt.Printf("Update failed for app %d: %v\n", app.ID, err) fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
} }
_, err = db.ExecContext(ctx,
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
app.ID,
isOnline,
)
if err != nil {
fmt.Printf("Insert into uptime_history failed for app %d: %v\n", app.ID, err)
}
} }
} }

View File

@@ -14,6 +14,10 @@ export async function POST(request: NextRequest) {
where: { id: id } where: { id: id }
}); });
await prisma.uptime_history.deleteMany({
where: { applicationId: id }
});
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error: any) { } catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });

View File

@@ -0,0 +1,40 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface EditRequest {
id: number;
name: string;
description: string;
serverId: number;
icon: string;
publicURL: string;
localURL: string;
}
export async function PUT(request: NextRequest) {
try {
const body: EditRequest = await request.json();
const { id, name, description, serverId, icon, publicURL, localURL } = body;
const existingApp = await prisma.application.findUnique({ where: { id } });
if (!existingApp) {
return NextResponse.json({ error: "Server not found" }, { status: 404 });
}
const updatedApplication = await prisma.application.update({
where: { id },
data: {
serverId,
name,
description,
icon,
publicURL,
localURL
}
});
return NextResponse.json({ message: "Application updated", application: updatedApplication });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import Fuse from "fuse.js";
interface SearchRequest {
searchterm: string;
}
export async function POST(request: NextRequest) {
try {
const body: SearchRequest = await request.json();
const { searchterm } = body;
const applications = await prisma.application.findMany({});
const fuseOptions = {
keys: ['name', 'description'],
threshold: 0.3,
includeScore: true,
};
const fuse = new Fuse(applications, fuseOptions);
const searchResults = fuse.search(searchterm);
const results = searchResults.map(({ item }) => item);
return NextResponse.json({ results });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,162 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface RequestBody {
timespan?: number;
page?: number;
}
const getTimeRange = (timespan: number) => {
const now = new Date();
switch (timespan) {
case 1:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
interval: 'minute'
};
case 2:
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
interval: '3hour'
};
case 3:
return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
interval: 'day'
};
default:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
interval: 'minute'
};
}
};
const generateIntervals = (timespan: number) => {
const now = new Date();
now.setSeconds(0, 0);
switch (timespan) {
case 1:
return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setMinutes(d.getMinutes() - i);
d.setSeconds(0, 0);
return d;
});
case 2:
return Array.from({ length: 56 }, (_, i) => {
const d = new Date(now);
d.setHours(d.getHours() - (i * 3));
d.setMinutes(0, 0, 0);
return d;
});
case 3:
return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - i);
d.setHours(0, 0, 0, 0);
return d;
});
default:
return [];
}
};
const getIntervalKey = (date: Date, timespan: number) => {
const d = new Date(date);
switch (timespan) {
case 1:
d.setSeconds(0, 0);
return d.toISOString();
case 2:
d.setHours(Math.floor(d.getHours() / 3) * 3);
d.setMinutes(0, 0, 0);
return d.toISOString();
case 3:
d.setHours(0, 0, 0, 0);
return d.toISOString();
default:
return d.toISOString();
}
};
export async function POST(request: NextRequest) {
try {
const { timespan = 1, page = 1 }: RequestBody = await request.json();
const itemsPerPage = 5;
const skip = (page - 1) * itemsPerPage;
// Get paginated and sorted applications
const [applications, totalCount] = await Promise.all([
prisma.application.findMany({
skip,
take: itemsPerPage,
orderBy: { name: 'asc' }
}),
prisma.application.count()
]);
const applicationIds = applications.map(app => app.id);
// Get time range and intervals
const { start } = getTimeRange(timespan);
const intervals = generateIntervals(timespan);
// Get uptime history for the filtered applications
const uptimeHistory = await prisma.uptime_history.findMany({
where: {
applicationId: { in: applicationIds },
createdAt: { gte: start }
},
orderBy: { createdAt: "desc" }
});
// Process data for each application
const result = applications.map(app => {
const appChecks = uptimeHistory.filter(check => check.applicationId === app.id);
const checksMap = new Map<string, { failed: number; total: number }>();
for (const check of appChecks) {
const intervalKey = getIntervalKey(check.createdAt, timespan);
const current = checksMap.get(intervalKey) || { failed: 0, total: 0 };
current.total++;
if (!check.online) current.failed++;
checksMap.set(intervalKey, current);
}
const uptimeSummary = intervals.map(interval => {
const intervalKey = getIntervalKey(interval, timespan);
const stats = checksMap.get(intervalKey);
return {
timestamp: intervalKey,
missing: !stats,
online: stats ? (stats.failed / stats.total) <= 0.5 : null
};
});
return {
appName: app.name,
appId: app.id,
uptimeSummary
};
});
return NextResponse.json({
data: result,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / itemsPerPage),
totalItems: totalCount
}
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
interface EditEmailRequest {
newEmail: string;
jwtToken: string;
}
export async function POST(request: NextRequest) {
try {
const body: EditEmailRequest = await request.json();
const { newEmail, jwtToken } = body;
// Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined');
}
// Verify JWT
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
if (!decoded.account_secret) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
// Get the user by account id
const user = await prisma.user.findUnique({
where: { id: decoded.account_secret },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if the new email is already in use
const existingUser = await prisma.user.findUnique({
where: { email: newEmail },
});
if (existingUser) {
return NextResponse.json({ error: 'Email already in use' }, { status: 400 });
}
// Update the user's email
await prisma.user.update({
where: { id: user.id },
data: { email: newEmail },
});
return NextResponse.json({ message: 'Email updated successfully' });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
import bcrypt from 'bcrypt';
interface EditEmailRequest {
oldPassword: string;
newPassword: string;
jwtToken: string;
}
export async function POST(request: NextRequest) {
try {
const body: EditEmailRequest = await request.json();
const { oldPassword, newPassword, jwtToken } = body;
// Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined');
}
// Verify JWT
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
if (!decoded.account_secret) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
// Get the user by account id
const user = await prisma.user.findUnique({
where: { id: decoded.account_secret },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if the old password is correct
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password);
if (!isOldPasswordValid) {
return NextResponse.json({ error: 'Old password is incorrect' }, { status: 401 });
}
// Hash the new password
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
// Update the user's password
await prisma.user.update({
where: { id: user.id },
data: { password: hashedNewPassword },
});
return NextResponse.json({ message: 'Password updated successfully' });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,5 +1,7 @@
import { NextResponse, NextRequest } from "next/server"; import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
import bcrypt from 'bcrypt';
interface LoginRequest { interface LoginRequest {
username: string; username: string;
@@ -11,17 +13,50 @@ export async function POST(request: NextRequest) {
const body: LoginRequest = await request.json(); const body: LoginRequest = await request.json();
const { username, password } = body; const { username, password } = body;
if(username !== process.env.LOGIN_EMAIL || password !== process.env.LOGIN_PASSWORD) {
throw new Error('Invalid credentials');
}
// Ensure JWT_SECRET is defined // Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined'); throw new Error('JWT_SECRET is not defined');
} }
let accountId: string = '';
// Check if there are any entries in user
const userCount = await prisma.user.count();
if (userCount === 0) {
if(username=== "admin@example.com" && password === "admin") {
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create the first user with hashed password
const user = await prisma.user.create({
data: {
email: username,
password: hashedPassword,
},
});
// Get the account id
accountId = user.id;
} else {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
} else {
// Get the user by username
const user = await prisma.user.findUnique({
where: { email: username },
});
if (!user) {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
// Check if the password is correct
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
// Get the account id
accountId = user.id;
}
// Create JWT // Create JWT
const token = jwt.sign({ account_secret: process.env.ACCOUNT_SECRET }, process.env.JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ account_secret: accountId }, process.env.JWT_SECRET, { expiresIn: '7d' });
return NextResponse.json({ token }); return NextResponse.json({ token });
} catch (error: any) { } catch (error: any) {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import jwt, { JwtPayload } from 'jsonwebtoken'; import jwt, { JwtPayload } from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
interface ValidateRequest { interface ValidateRequest {
token: string; token: string;
@@ -16,6 +16,14 @@ export async function POST(request: NextRequest) {
throw new Error('JWT_SECRET is not defined'); throw new Error('JWT_SECRET is not defined');
} }
// Get the account id
const user = await prisma.user.findFirst({
where: {},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify JWT // Verify JWT
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string }; const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
@@ -23,7 +31,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
} }
if(decoded.account_secret !== process.env.ACCOUNT_SECRET) { if(decoded.account_secret !== user.id) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
} }

View File

@@ -10,6 +10,9 @@ interface Node {
}; };
position: { x: number; y: number }; position: { x: number; y: number };
style: React.CSSProperties; style: React.CSSProperties;
draggable?: boolean;
selectable?: boolean;
zIndex?: number;
} }
interface Edge { interface Edge {
@@ -38,10 +41,13 @@ interface Application {
const NODE_WIDTH = 220; const NODE_WIDTH = 220;
const NODE_HEIGHT = 60; const NODE_HEIGHT = 60;
const APP_NODE_WIDTH = 160;
const APP_NODE_HEIGHT = 40;
const HORIZONTAL_SPACING = 280; const HORIZONTAL_SPACING = 280;
const VERTICAL_SPACING = 80; const VERTICAL_SPACING = 60;
const START_Y = 120; const START_Y = 120;
const ROOT_NODE_WIDTH = 300; const ROOT_NODE_WIDTH = 300;
const CONTAINER_PADDING = 40;
export async function GET() { export async function GET() {
try { try {
@@ -54,11 +60,12 @@ export async function GET() {
}) as Promise<Application[]>, }) as Promise<Application[]>,
]); ]);
// Root Node
const rootNode: Node = { const rootNode: Node = {
id: "root", id: "root",
type: "infrastructure", type: "infrastructure",
data: { label: "My Infrastructure" }, data: { label: "My Infrastructure" },
position: { x: 0, y: 20 }, position: { x: 0, y: 0 },
style: { style: {
background: "#ffffff", background: "#ffffff",
color: "#0f0f0f", color: "#0f0f0f",
@@ -72,6 +79,7 @@ export async function GET() {
}, },
}; };
// Server Nodes
const serverNodes: Node[] = servers.map((server, index) => { const serverNodes: Node[] = servers.map((server, index) => {
const xPos = const xPos =
index * HORIZONTAL_SPACING - index * HORIZONTAL_SPACING -
@@ -100,11 +108,12 @@ export async function GET() {
}; };
}); });
// Application Nodes
const appNodes: Node[] = []; const appNodes: Node[] = [];
servers.forEach((server) => { servers.forEach((server) => {
const serverX = const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
serverNodes.find((n) => n.id === `server-${server.id}`)?.position.x || 0; const serverX = serverNode?.position.x || 0;
const serverY = START_Y; const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
applications applications
.filter((app) => app.serverId === server.id) .filter((app) => app.serverId === server.id)
@@ -117,25 +126,26 @@ export async function GET() {
...app, ...app,
}, },
position: { position: {
x: serverX, x: serverX + xOffset,
y: serverY + NODE_HEIGHT + 40 + appIndex * VERTICAL_SPACING, y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
}, },
style: { style: {
background: "#ffffff", background: "#f5f5f5",
color: "#0f0f0f", color: "#0f0f0f",
border: "2px solid #e6e4e1", border: "2px solid #e6e4e1",
borderRadius: "4px", borderRadius: "4px",
padding: "8px", padding: "6px",
width: NODE_WIDTH, width: APP_NODE_WIDTH,
height: NODE_HEIGHT, height: APP_NODE_HEIGHT,
fontSize: "0.9rem", fontSize: "0.8rem",
lineHeight: "1.2", lineHeight: "1.1",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}, },
}); });
}); });
}); });
// Connections
const connections: Edge[] = [ const connections: Edge[] = [
...servers.map((server) => ({ ...servers.map((server) => ({
id: `conn-root-${server.id}`, id: `conn-root-${server.id}`,
@@ -159,8 +169,46 @@ export async function GET() {
})), })),
]; ];
// Container Box
const allNodes = [rootNode, ...serverNodes, ...appNodes];
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
allNodes.forEach((node) => {
const width = parseInt(node.style.width?.toString() || "0", 10);
const height = parseInt(node.style.height?.toString() || "0", 10);
minX = Math.min(minX, node.position.x);
maxX = Math.max(maxX, node.position.x + width);
minY = Math.min(minY, node.position.y);
maxY = Math.max(maxY, node.position.y + height);
});
const containerNode: Node = {
id: 'container',
type: 'container',
data: { label: '' },
position: {
x: minX - CONTAINER_PADDING,
y: minY - CONTAINER_PADDING
},
style: {
width: maxX - minX + 2 * CONTAINER_PADDING,
height: maxY - minY + 2 * CONTAINER_PADDING,
background: 'transparent',
border: '2px dashed #e2e8f0',
borderRadius: '8px',
zIndex: 0,
},
draggable: false,
selectable: false,
zIndex: -1,
};
return NextResponse.json({ return NextResponse.json({
nodes: [rootNode, ...serverNodes, ...appNodes], nodes: [containerNode, ...allNodes],
edges: connections, edges: connections,
}); });
} catch (error: unknown) { } catch (error: unknown) {

View File

@@ -10,6 +10,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing ID" }, { status: 400 }); return NextResponse.json({ error: "Missing ID" }, { status: 400 });
} }
// Check if there are any applications associated with the server
const applications = await prisma.application.findMany({
where: { serverId: id }
});
if (applications.length > 0) {
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
}
await prisma.server.delete({ await prisma.server.delete({
where: { id: id } where: { id: id }
}); });

View File

@@ -2,15 +2,16 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
interface GetRequest { interface GetRequest {
page: number; page?: number;
ITEMS_PER_PAGE?: number;
} }
const ITEMS_PER_PAGE = 5;
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: GetRequest = await request.json(); const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1); const page = Math.max(1, body.page || 1);
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
const servers = await prisma.server.findMany({ const servers = await prisma.server.findMany({
skip: (page - 1) * ITEMS_PER_PAGE, skip: (page - 1) * ITEMS_PER_PAGE,

View File

@@ -0,0 +1,32 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import Fuse from "fuse.js";
interface SearchRequest {
searchterm: string;
}
export async function POST(request: NextRequest) {
try {
const body: SearchRequest = await request.json();
const { searchterm } = body;
const servers = await prisma.server.findMany({});
const fuseOptions = {
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk'],
threshold: 0.3,
includeScore: true,
};
const fuse = new Fuse(servers, fuseOptions);
const searchResults = fuse.search(searchterm);
const results = searchResults.map(({ item }) => item);
return NextResponse.json({ results });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,62 +1,61 @@
import { AppSidebar } from "@/components/app-sidebar"; "use client"
import { useEffect, useState } from "react"
import axios from "axios"
import Link from "next/link"
import { Activity, Layers, Network, Server } from "lucide-react"
import { AppSidebar } from "@/components/app-sidebar"
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink,
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, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
SidebarProvider, import { Button } from "@/components/ui/button"
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios"; // Korrekter Import
import { Card, CardHeader } from "@/components/ui/card";
interface StatsResponse { interface StatsResponse {
serverCount: number; serverCount: number
applicationCount: number; applicationCount: number
onlineApplicationsCount: number; onlineApplicationsCount: number
} }
export default function Dashboard() { export default function Dashboard() {
const [serverCount, setServerCount] = useState<number>(0); const [serverCount, setServerCount] = 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); setServerCount(response.data.serverCount)
setApplicationCount(response.data.applicationCount); setApplicationCount(response.data.applicationCount)
setOnlineApplicationsCount(response.data.onlineApplicationsCount); setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) { } catch (error: any) {
console.log("Axios error:", error.response?.data); console.log("Axios error:", error.response?.data)
} }
}; }
useEffect(() => { useEffect(() => {
getStats(); getStats()
}, []); }, [])
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem className="hidden md:block"> <BreadcrumbItem className="hidden md:block">
<BreadcrumbPage> <BreadcrumbPage>/</BreadcrumbPage>
/
</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" /> <BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem> <BreadcrumbItem>
@@ -66,51 +65,105 @@ export default function Dashboard() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<div className="pl-4 pr-4"> <div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
<Card className="w-full mb-4 relative">
<CardHeader> <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
<div className="flex items-center justify-center w-full"> <Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
<div className="flex flex-col items-center justify-center"> <CardHeader className="pb-2">
<span className="text-2xl font-bold">{serverCount}</span> <div className="flex items-center justify-between">
<span className="text-md">Servers</span> <CardTitle className="text-xl font-medium">Servers</CardTitle>
</div> <Server className="h-6 w-6 text-rose-500" />
</div> </div>
<CardDescription>Manage your server infrastructure</CardDescription>
</CardHeader> </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>
</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>
</Button>
</CardFooter>
</Card> </Card>
<Card className="w-full mb-4 relative">
<CardHeader> <Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md">
<div className="flex items-center justify-center w-full"> <CardHeader className="pb-2">
<div className="flex flex-col items-center justify-center"> <div className="flex items-center justify-between">
<span className="text-2xl font-bold">{applicationCount}</span> <CardTitle className="text-xl font-medium">Applications</CardTitle>
<span className="text-md">Applications</span> <Layers className="h-6 w-6 text-amber-500" />
</div>
</div> </div>
<CardDescription>Manage your deployed applications</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Running applications</p>
</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/applications">View all applications</Link>
</Button>
</CardFooter>
</Card> </Card>
<Card className="w-full mb-4 relative">
<CardHeader> <Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-sm transition-all hover:shadow-md">
<div className="flex items-center justify-center w-full"> <CardHeader className="pb-2">
<div className="flex flex-col items-center justify-center"> <div className="flex items-center justify-between">
<span className="text-2xl font-bold"> <CardTitle className="text-xl font-medium">Uptime</CardTitle>
<Activity className="h-6 w-6 text-emerald-500" />
</div>
<CardDescription>Monitor your service availability</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="flex flex-col">
<div className="text-4xl font-bold flex items-center justify-between">
<span>
{onlineApplicationsCount}/{applicationCount} {onlineApplicationsCount}/{applicationCount}
</span> </span>
<span className="text-md">Applications are online</span> <div className="flex items-center bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-lg font-semibold">
{applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%
</div>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-3">
<div
className="bg-emerald-500 h-2.5 rounded-full"
style={{
width: `${applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%`,
}}
></div>
</div>
<p className="text-sm text-muted-foreground mt-2">Online applications</p>
</div> </div>
</CardHeader> </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/uptime">View uptime metrics</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Network</CardTitle>
<Network className="h-6 w-6 text-sky-500" />
</div>
<CardDescription>Manage network configuration</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active connections</p>
</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/network">View network details</Link>
</Button>
</CardFooter>
</Card> </Card>
</div>
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
<span className="text-gray-400 text-2xl">COMING SOON</span>
</div>
<div className="pt-4">
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
<span className="text-gray-400 text-2xl">COMING SOON</span>
</div>
</div> </div>
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
); )
} }

View File

@@ -16,7 +16,16 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, Link, Home, Trash2, LayoutGrid, List } from "lucide-react"; import {
Plus,
Link,
Home,
Trash2,
LayoutGrid,
List,
Pencil,
Zap,
} from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -58,6 +67,12 @@ import {
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import axios from "axios"; import axios from "axios";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface Application { interface Application {
id: number; id: number;
@@ -89,6 +104,15 @@ export default function Dashboard() {
const [publicURL, setPublicURL] = useState<string>(""); const [publicURL, setPublicURL] = useState<string>("");
const [localURL, setLocalURL] = useState<string>(""); const [localURL, setLocalURL] = useState<string>("");
const [serverId, setServerId] = useState<number | null>(null); const [serverId, setServerId] = useState<number | null>(null);
const [editName, setEditName] = useState<string>("");
const [editDescription, setEditDescription] = useState<string>("");
const [editIcon, setEditIcon] = useState<string>("");
const [editPublicURL, setEditPublicURL] = useState<string>("");
const [editLocalURL, setEditLocalURL] = useState<string>("");
const [editId, setEditId] = useState<number | null>(null);
const [editServerId, setEditServerId] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [maxPage, setMaxPage] = useState<number>(1); const [maxPage, setMaxPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(5); const [itemsPerPage, setItemsPerPage] = useState<number>(5);
@@ -97,9 +121,12 @@ export default function Dashboard() {
const [isGridLayout, setIsGridLayout] = useState<boolean>(false); const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [searchTerm, setSearchTerm] = useState<string>("");
const [isSearching, setIsSearching] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const savedLayout = Cookies.get('layoutPreference-app'); const savedLayout = Cookies.get("layoutPreference-app");
const layout_bool = savedLayout === 'grid'; const layout_bool = savedLayout === "grid";
setIsGridLayout(layout_bool); setIsGridLayout(layout_bool);
setItemsPerPage(layout_bool ? 15 : 5); setItemsPerPage(layout_bool ? 15 : 5);
}, []); }, []);
@@ -107,35 +134,35 @@ export default function Dashboard() {
const toggleLayout = () => { const toggleLayout = () => {
const newLayout = !isGridLayout; const newLayout = !isGridLayout;
setIsGridLayout(newLayout); setIsGridLayout(newLayout);
Cookies.set('layoutPreference-app', newLayout ? 'grid' : 'standard', { Cookies.set("layoutPreference-app", newLayout ? "grid" : "standard", {
expires: 365, expires: 365,
path: '/', path: "/",
sameSite: 'strict' sameSite: "strict",
}); });
setItemsPerPage(newLayout ? 15 : 5); setItemsPerPage(newLayout ? 15 : 5);
}; };
const add = async () => { const add = async () => {
try { try {
await axios.post('/api/applications/add', { await axios.post("/api/applications/add", {
name, name,
description, description,
icon, icon,
publicURL, publicURL,
localURL, localURL,
serverId serverId,
}); });
getApplications(); getApplications();
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
} }
} };
const getApplications = async () => { const getApplications = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await axios.post<ApplicationsResponse>( const response = await axios.post<ApplicationsResponse>(
'/api/applications/get', "/api/applications/get",
{ page: currentPage, ITEMS_PER_PAGE: itemsPerPage } { page: currentPage, ITEMS_PER_PAGE: itemsPerPage }
); );
setApplications(response.data.applications); setApplications(response.data.applications);
@@ -145,29 +172,95 @@ export default function Dashboard() {
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
} }
} };
useEffect(() => { useEffect(() => {
getApplications(); getApplications();
}, [currentPage, itemsPerPage]); }, [currentPage, itemsPerPage]);
const handlePrevious = () => setCurrentPage(prev => Math.max(1, prev - 1)); const handlePrevious = () => setCurrentPage((prev) => Math.max(1, prev - 1));
const handleNext = () => setCurrentPage(prev => Math.min(maxPage, prev + 1)); const handleNext = () =>
setCurrentPage((prev) => Math.min(maxPage, prev + 1));
const deleteApplication = async (id: number) => { const deleteApplication = async (id: number) => {
try { try {
await axios.post('/api/applications/delete', { id }); await axios.post("/api/applications/delete", { id });
getApplications(); getApplications();
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
} }
};
const openEditDialog = (app: Application) => {
setEditId(app.id);
setEditServerId(app.serverId);
setEditName(app.name);
setEditDescription(app.description || "");
setEditIcon(app.icon || "");
setEditLocalURL(app.localURL || "");
setEditPublicURL(app.publicURL || "");
};
const edit = async () => {
if (!editId) return;
try {
await axios.put("/api/applications/edit", {
id: editId,
serverId: editServerId,
name: editName,
description: editDescription,
icon: editIcon,
publicURL: editPublicURL,
localURL: editLocalURL,
});
getApplications();
setEditId(null);
} catch (error: any) {
console.log(error.response.data);
}
};
const searchApplications = async () => {
try {
setIsSearching(true);
const response = await axios.post<{ results: Application[] }>(
"/api/applications/search",
{ searchterm: searchTerm }
);
setApplications(response.data.results);
setIsSearching(false);
} catch (error: any) {
console.error("Search error:", error.response?.data);
setIsSearching(false);
}
};
useEffect(() => {
const delayDebounce = setTimeout(() => {
if (searchTerm.trim() === "") {
getApplications();
} else {
searchApplications();
}
}, 300);
return () => clearTimeout(delayDebounce);
}, [searchTerm]);
const generateIconURL = async () => {
setIcon("https://cdn.jsdelivr.net/gh/selfhst/icons/png/" + name.toLowerCase() + ".png")
}
const generateEditIconURL = async () => {
setEditIcon("https://cdn.jsdelivr.net/gh/selfhst/icons/png/" + editName.toLowerCase() + ".png")
} }
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
@@ -188,20 +281,28 @@ export default function Dashboard() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<div className="pl-4 pr-4"> <div className="p-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-2xl font-semibold">Your Applications</span> <span className="text-3xl font-bold">Your Applications</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={toggleLayout} onClick={toggleLayout}
title={isGridLayout ? "Switch to list view" : "Switch to grid view"} title={
isGridLayout ? "Switch to list view" : "Switch to grid view"
}
> >
{isGridLayout ? <List className="h-4 w-4" /> : <LayoutGrid className="h-4 w-4" />} {isGridLayout ? (
<List className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</Button> </Button>
{servers.length === 0 ? ( {servers.length === 0 ? (
<p className="text-muted-foreground">You must first add a server.</p> <p className="text-muted-foreground">
You must first add a server.
</p>
) : ( ) : (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -214,153 +315,380 @@ export default function Dashboard() {
<AlertDialogTitle>Add an application</AlertDialogTitle> <AlertDialogTitle>Add an application</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label>Name</Label> <Label>Name</Label>
<Input placeholder="e.g. Portainer" onChange={(e) => setName(e.target.value)}/> <Input
</div> placeholder="e.g. Portainer"
<div className="grid w-full items-center gap-1.5"> onChange={(e) => setName(e.target.value)}
<Label>Server</Label> />
<Select onValueChange={(v) => setServerId(Number(v))} required> </div>
<SelectTrigger className="w-full"> <div className="grid w-full items-center gap-1.5">
<SelectValue placeholder="Select server" /> <Label>Server</Label>
</SelectTrigger> <Select
<SelectContent> onValueChange={(v) => setServerId(Number(v))}
{servers.map((server) => ( required
<SelectItem key={server.id} value={String(server.id)}> >
{server.name} <SelectTrigger className="w-full">
</SelectItem> <SelectValue placeholder="Select server" />
))} </SelectTrigger>
</SelectContent> <SelectContent>
</Select> {servers.map((server) => (
</div> <SelectItem
<div className="grid w-full items-center gap-1.5"> key={server.id}
<Label>Description <span className="text-stone-600">(optional)</span></Label> value={String(server.id)}
<Textarea placeholder="Application description" onChange={(e) => setDescription(e.target.value)}/> >
</div> {server.name}
<div className="grid w-full items-center gap-1.5"> </SelectItem>
<Label>Icon URL <span className="text-stone-600">(optional)</span></Label> ))}
<Input placeholder="https://example.com/icon.png" onChange={(e) => setIcon(e.target.value)}/> </SelectContent>
</div> </Select>
<div className="grid w-full items-center gap-1.5"> </div>
<Label>Public URL</Label> <div className="grid w-full items-center gap-1.5">
<Input placeholder="https://example.com" onChange={(e) => setPublicURL(e.target.value)}/> <Label>
</div> Description{" "}
<div className="grid w-full items-center gap-1.5"> <span className="text-stone-600">(optional)</span>
<Label>Local URL <span className="text-stone-600">(optional)</span></Label> </Label>
<Input placeholder="http://localhost:3000" onChange={(e) => setLocalURL(e.target.value)}/> <Textarea
</div> placeholder="Application description"
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Icon URL{" "}
<span className="text-stone-600">(optional)</span>
</Label>
<div className="flex gap-2">
<Input
value={icon}
placeholder="https://example.com/icon.png"
onChange={(e) => setIcon(e.target.value)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="outline" size="icon" onClick={generateIconURL}>
<Zap />
</Button>
</TooltipTrigger>
<TooltipContent>
Generate Icon URL
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Public URL</Label>
<Input
placeholder="https://example.com"
onChange={(e) => setPublicURL(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Local URL{" "}
<span className="text-stone-600">(optional)</span>
</Label>
<Input
placeholder="http://localhost:3000"
onChange={(e) => setLocalURL(e.target.value)}
/>
</div>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={add} disabled={!name || !publicURL || !serverId}> <AlertDialogAction
onClick={add}
disabled={!name || !publicURL || !serverId}
>
Add Add
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
</div>
</div> </div>
<div className="flex flex-col gap-2 mb-4 pt-2">
<Input
id="application-search"
placeholder="Type to search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div> </div>
<br /> <br />
{!loading ? {!loading ? (
<div className={isGridLayout ? <div
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" : className={
"space-y-4"}> isGridLayout
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
: "space-y-4"
}
>
{applications.map((app) => ( {applications.map((app) => (
<Card <Card
key={app.id} key={app.id}
className={isGridLayout ? "h-full flex flex-col justify-between relative" : "w-full mb-4 relative"} className={
> isGridLayout
<CardHeader> ? "h-full flex flex-col justify-between relative"
<div className="absolute top-2 right-2"> : "w-full mb-4 relative"
<div }
className={`w-4 h-4 rounded-full flex items-center justify-center ${app.online ? "bg-green-700" : "bg-red-700"}`} >
title={app.online ? "Online" : "Offline"} <CardHeader>
> <div className="absolute top-2 right-2">
<div className={`w-2 h-2 rounded-full ${app.online ? "bg-green-500" : "bg-red-500"}`} /> <div
</div> className={`w-4 h-4 rounded-full flex items-center justify-center ${
</div> app.online ? "bg-green-700" : "bg-red-700"
<div className="flex items-center justify-between w-full"> }`}
<div className="flex items-center"> title={app.online ? "Online" : "Offline"}
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md"> >
{app.icon ? ( <div
<img src={app.icon} alt={app.name} className="w-full h-full object-contain rounded-md" /> className={`w-2 h-2 rounded-full ${
) : ( app.online ? "bg-green-500" : "bg-red-500"
<span className="text-gray-500 text-xs">Image</span> }`}
)} />
</div>
<div className="ml-4">
<CardTitle className="text-2xl font-bold">{app.name}</CardTitle>
<CardDescription className="text-md">
{app.description}<br />
Server: {app.server || 'No server'}
</CardDescription>
</div> </div>
</div> </div>
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center">
<div className="flex flex-col space-y-2 flex-grow"> <div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
<Button {app.icon ? (
variant="outline" <img
className="gap-2 w-full" src={app.icon}
onClick={() => window.open(app.publicURL, "_blank")} alt={app.name}
> className="w-full h-full object-contain rounded-md"
<Link className="h-4 w-4" /> />
Open Public URL ) : (
</Button> <span className="text-gray-500 text-xs">Image</span>
{app.localURL && ( )}
</div>
<div className="ml-4">
<CardTitle className="text-2xl font-bold">
{app.name}
</CardTitle>
<CardDescription className="text-md">
{app.description}
{app.description && (
<br className="hidden md:block" />
)}
Server: {app.server || "No server"}
</CardDescription>
</div>
</div>
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]">
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col space-y-2 flex-grow">
<Button <Button
variant="outline" variant="outline"
className="gap-2 w-full" className="gap-2 w-full"
onClick={() => window.open(app.localURL, "_blank")} onClick={() =>
window.open(app.publicURL, "_blank")
}
> >
<Home className="h-4 w-4" /> <Link className="h-4 w-4" />
Open Local URL Open Public URL
</Button> </Button>
)} {app.localURL && (
<Button
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.localURL, "_blank")
}
>
<Home className="h-4 w-4" />
Open Local URL
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<Button
variant="destructive"
size="icon"
className="h-9 w-9"
onClick={() => deleteApplication(app.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
className="h-9 w-9"
onClick={() => openEditDialog(app)}
>
<Pencil className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Edit Application
</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label>Name</Label>
<Input
placeholder="e.g. Portainer"
value={editName}
onChange={(e) =>
setEditName(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Server</Label>
<Select
value={
editServerId !== null
? String(editServerId)
: undefined
}
onValueChange={(v) =>
setEditServerId(Number(v))
}
required
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select server" />
</SelectTrigger>
<SelectContent>
{servers.map((server) => (
<SelectItem
key={server.id}
value={String(server.id)}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Description{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Textarea
placeholder="Application description"
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Icon URL{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<div className="flex gap-2">
<Input
placeholder="https://example.com/icon.png"
value={editIcon}
onChange={(e) =>
setEditIcon(e.target.value)
}
/>
<Button variant="outline" size="icon" onClick={generateEditIconURL}>
<Zap />
</Button>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Public URL</Label>
<Input
placeholder="https://example.com"
value={editPublicURL}
onChange={(e) =>
setEditPublicURL(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Local URL{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Input
placeholder="http://localhost:3000"
value={editLocalURL}
onChange={(e) =>
setEditLocalURL(e.target.value)
}
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={edit}
disabled={
!editName || !editPublicURL || !editServerId
}
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
<Button
variant="destructive"
size="icon"
className="h-[72px] w-10"
onClick={() => deleteApplication(app.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> </CardHeader>
</CardHeader> </Card>
</Card> ))}
))}
</div> </div>
: ) : (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<div className='inline-block' role='status' aria-label='loading'> <div className="inline-block" role="status" aria-label="loading">
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'> <svg
<g clip-path='url(#clip0_9023_61563)'> className="w-6 h-6 stroke-white animate-spin "
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path> viewBox="0 0 24 24"
</g> fill="none"
<defs> xmlns="http://www.w3.org/2000/svg"
<clipPath id='clip0_9023_61563'> >
<rect width='24' height='24' fill='white'></rect> <g clip-path="url(#clip0_9023_61563)">
<path
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
stroke="stroke-current"
stroke-width="1.4"
stroke-linecap="round"
className="my-path"
></path>
</g>
<defs>
<clipPath id="clip0_9023_61563">
<rect width="24" height="24" fill="white"></rect>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
</div> </div>
} )}
<div className="pt-4"> <div className="pt-4 pb-4">
<Pagination> <Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
href="#"
onClick={handlePrevious} onClick={handlePrevious}
isActive={currentPage > 1} isActive={currentPage > 1}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
/> />
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
@@ -368,9 +696,9 @@ export default function Dashboard() {
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext
href="#"
onClick={handleNext} onClick={handleNext}
isActive={currentPage < maxPage} isActive={currentPage < maxPage}
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
/> />
</PaginationItem> </PaginationItem>
</PaginationContent> </PaginationContent>
@@ -379,5 +707,5 @@ export default function Dashboard() {
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) );
} }

View File

@@ -41,7 +41,7 @@ export default function Dashboard() {
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset className="flex flex-col h-screen"> <SidebarInset className="flex flex-col h-screen">
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 dark:text-white" /> <SidebarTrigger className="-ml-1 dark:text-white" />
<Separator <Separator

View File

@@ -16,7 +16,20 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, Link, MonitorCog, FileDigit, Trash2, LayoutGrid, List, Pencil, Cpu, Microchip, MemoryStick, HardDrive } from "lucide-react"; import {
Plus,
Link,
MonitorCog,
FileDigit,
Trash2,
LayoutGrid,
List,
Pencil,
Cpu,
Microchip,
MemoryStick,
HardDrive,
} from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -62,7 +75,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import axios from 'axios'; import axios from "axios";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface Server { interface Server {
@@ -94,6 +107,7 @@ export default function Dashboard() {
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [maxPage, setMaxPage] = useState<number>(1); const [maxPage, setMaxPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(4);
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [isGridLayout, setIsGridLayout] = useState<boolean>(false); const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
@@ -108,24 +122,30 @@ export default function Dashboard() {
const [editRam, setEditRam] = useState<string>(""); const [editRam, setEditRam] = useState<string>("");
const [editDisk, setEditDisk] = useState<string>(""); const [editDisk, setEditDisk] = useState<string>("");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isSearching, setIsSearching] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const savedLayout = Cookies.get('layoutPreference-servers'); const savedLayout = Cookies.get("layoutPreference-servers");
setIsGridLayout(savedLayout === 'grid'); const layout_bool = savedLayout === "grid";
setIsGridLayout(layout_bool);
setItemsPerPage(layout_bool ? 6 : 4);
}, []); }, []);
const toggleLayout = () => { const toggleLayout = () => {
const newLayout = !isGridLayout; const newLayout = !isGridLayout;
setIsGridLayout(newLayout); setIsGridLayout(newLayout);
Cookies.set('layoutPreference-servers', newLayout ? 'grid' : 'standard', { Cookies.set("layoutPreference-servers", newLayout ? "grid" : "standard", {
expires: 365, expires: 365,
path: '/', path: "/",
sameSite: 'strict' sameSite: "strict",
}); });
setItemsPerPage(newLayout ? 6 : 4);
}; };
const add = async () => { const add = async () => {
try { try {
await axios.post('/api/servers/add', { await axios.post("/api/servers/add", {
name, name,
os, os,
ip, ip,
@@ -133,48 +153,52 @@ export default function Dashboard() {
cpu, cpu,
gpu, gpu,
ram, ram,
disk disk,
}); });
getServers(); getServers();
} catch (error: any) { } catch (error: any) {
console.log(error.response.data); console.log(error.response.data);
} }
} };
const getServers = async () => { const getServers = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await axios.post<GetServersResponse>('/api/servers/get', { const response = await axios.post<GetServersResponse>(
page: currentPage "/api/servers/get",
}); {
page: currentPage,
ITEMS_PER_PAGE: itemsPerPage,
}
);
setServers(response.data.servers); setServers(response.data.servers);
setMaxPage(response.data.maxPage); setMaxPage(response.data.maxPage);
setLoading(false); setLoading(false);
} catch (error: any) { } catch (error: any) {
console.log(error.response); console.log(error.response);
} }
} };
useEffect(() => { useEffect(() => {
getServers(); getServers();
}, [currentPage]); }, [currentPage, itemsPerPage]);
const handlePrevious = () => { const handlePrevious = () => {
setCurrentPage(prev => Math.max(1, prev - 1)); setCurrentPage((prev) => Math.max(1, prev - 1));
} };
const handleNext = () => { const handleNext = () => {
setCurrentPage(prev => Math.min(maxPage, prev + 1)); setCurrentPage((prev) => Math.min(maxPage, prev + 1));
} };
const deleteApplication = async (id: number) => { const deleteApplication = async (id: number) => {
try { try {
await axios.post('/api/servers/delete', { id }); await axios.post("/api/servers/delete", { id });
getServers(); getServers();
} catch (error: any) { } catch (error: any) {
console.log(error.response.data); console.log(error.response.data);
} }
} };
const openEditDialog = (server: Server) => { const openEditDialog = (server: Server) => {
setEditId(server.id); setEditId(server.id);
@@ -192,7 +216,7 @@ export default function Dashboard() {
if (!editId) return; if (!editId) return;
try { try {
await axios.put('/api/servers/edit', { await axios.put("/api/servers/edit", {
id: editId, id: editId,
name: editName, name: editName,
os: editOs, os: editOs,
@@ -201,21 +225,48 @@ export default function Dashboard() {
cpu: editCpu, cpu: editCpu,
gpu: editGpu, gpu: editGpu,
ram: editRam, ram: editRam,
disk: editDisk disk: editDisk,
}); });
getServers(); getServers();
setEditId(null); setEditId(null);
} catch (error: any) { } catch (error: any) {
console.log(error.response.data); console.log(error.response.data);
} }
} };
const searchServers = async () => {
try {
setIsSearching(true);
const response = await axios.post<{ results: Server[] }>(
"/api/servers/search",
{ searchterm: searchTerm }
);
setServers(response.data.results);
setIsSearching(false);
} catch (error: any) {
console.error("Search error:", error.response?.data);
setIsSearching(false);
}
};
useEffect(() => {
const delayDebounce = setTimeout(() => {
if (searchTerm.trim() === "") {
getServers();
} else {
searchServers();
}
}, 300);
return () => clearTimeout(delayDebounce);
}, [searchTerm]);
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb> <Breadcrumb>
@@ -235,9 +286,9 @@ export default function Dashboard() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<div className="pl-4 pr-4"> <div className="p-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-2xl font-semibold">Your Servers</span> <span className="text-3xl font-bold">Your Servers</span>
<div className="flex gap-2"> <div className="flex gap-2">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -255,9 +306,9 @@ export default function Dashboard() {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{isGridLayout ? {isGridLayout
"Switch to list view" : ? "Switch to list view"
"Switch to grid view"} : "Switch to grid view"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -271,70 +322,144 @@ export default function Dashboard() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Add an server</AlertDialogTitle> <AlertDialogTitle>Add an server</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
<Tabs defaultValue="general" className="w-full"> <Tabs defaultValue="general" className="w-full">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger> <TabsTrigger value="hardware">Hardware</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general"> <TabsContent value="general">
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input id="name" type="text" placeholder="e.g. Server1" onChange={(e) => setName(e.target.value)}/> <Input
</div> id="name"
<div className="grid w-full items-center gap-1.5"> type="text"
<Label htmlFor="description">Operating System <span className="text-stone-600">(optional)</span></Label> placeholder="e.g. Server1"
<Select onValueChange={(value) => setOs(value)}> onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="description">
Operating System{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Select onValueChange={(value) => setOs(value)}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select OS" /> <SelectValue placeholder="Select OS" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Windows">Windows</SelectItem> <SelectItem value="Windows">
<SelectItem value="Linux">Linux</SelectItem> Windows
<SelectItem value="MacOS">MacOS</SelectItem> </SelectItem>
<SelectItem value="Linux">Linux</SelectItem>
<SelectItem value="MacOS">MacOS</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="icon">IP Adress <span className="text-stone-600">(optional)</span></Label> <Label htmlFor="icon">
<Input id="icon" type="text" placeholder="e.g. 192.168.100.2" onChange={(e) => setIp(e.target.value)}/> IP Adress{" "}
</div> <span className="text-stone-600">
<div className="grid w-full items-center gap-1.5"> (optional)
<TooltipProvider> </span>
</Label>
<Input
id="icon"
type="text"
placeholder="e.g. 192.168.100.2"
onChange={(e) => setIp(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Label htmlFor="publicURL">Management URL <span className="text-stone-600">(optional)</span></Label> <Label htmlFor="publicURL">
</TooltipTrigger> Management URL{" "}
<TooltipContent> <span className="text-stone-600">
Link to a web interface (e.g. Proxmox or Portainer) with which the server can be managed (optional)
</TooltipContent> </span>
</Label>
</TooltipTrigger>
<TooltipContent>
Link to a web interface (e.g. Proxmox or
Portainer) with which the server can be
managed
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<Input id="publicURL" type="text" placeholder="e.g. https://proxmox.server1.com" onChange={(e) => setUrl(e.target.value)}/> <Input
id="publicURL"
type="text"
placeholder="e.g. https://proxmox.server1.com"
onChange={(e) => setUrl(e.target.value)}
/>
</div>
</div> </div>
</div> </TabsContent>
</TabsContent> <TabsContent value="hardware">
<TabsContent value="hardware"> <div className="space-y-4 pt-4">
<div className="space-y-4 pt-4"> <div className="grid w-full items-center gap-1.5">
<div className="grid w-full items-center gap-1.5"> <Label htmlFor="name">
<Label htmlFor="name">CPU <span className="text-stone-600">(optional)</span></Label> CPU{" "}
<Input id="name" type="text" placeholder="e.g. AMD Ryzen™ 7 7800X3D" onChange={(e) => setCpu(e.target.value)}/> <span className="text-stone-600">
(optional)
</span>
</Label>
<Input
id="name"
type="text"
placeholder="e.g. AMD Ryzen™ 7 7800X3D"
onChange={(e) => setCpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">
GPU{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Input
id="name"
type="text"
placeholder="e.g. AMD Radeon™ Graphics"
onChange={(e) => setGpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">
RAM{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Input
id="name"
type="text"
placeholder="e.g. 64GB DDR5"
onChange={(e) => setRam(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">
Disk{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Input
id="name"
type="text"
placeholder="e.g. 2TB SSD"
onChange={(e) => setDisk(e.target.value)}
/>
</div>
</div> </div>
<div className="grid w-full items-center gap-1.5"> </TabsContent>
<Label htmlFor="name">GPU <span className="text-stone-600">(optional)</span></Label> </Tabs>
<Input id="name" type="text" placeholder="e.g. AMD Radeon™ Graphics" onChange={(e) => setGpu(e.target.value)}/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">RAM <span className="text-stone-600">(optional)</span></Label>
<Input id="name" type="text" placeholder="e.g. 64GB DDR5" onChange={(e) => setRam(e.target.value)}/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Disk <span className="text-stone-600">(optional)</span></Label>
<Input id="name" type="text" placeholder="e.g. 2TB SSD" onChange={(e) => setDisk(e.target.value)}/>
</div>
</div>
</TabsContent>
</Tabs>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -345,31 +470,57 @@ export default function Dashboard() {
</AlertDialog> </AlertDialog>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 mb-4 pt-2">
<Input
id="application-search"
placeholder="Type to search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<br /> <br />
{!loading ? {!loading ? (
<div className={isGridLayout ? <div
"grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4" : className={
"space-y-4"}> isGridLayout
? "grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4"
: "space-y-4"
}
>
{servers.map((server) => ( {servers.map((server) => (
<Card <Card
key={server.id} key={server.id}
className={isGridLayout ? className={
"h-full flex flex-col justify-between" : isGridLayout
"w-full mb-4"} ? "h-full flex flex-col justify-between"
: "w-full mb-4"
}
> >
<CardHeader> <CardHeader>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center"> <div className="flex items-center">
<div className="ml-4"> <div className="ml-4">
<CardTitle className="text-2xl font-bold">{server.name}</CardTitle> <CardTitle className="text-2xl font-bold">
<CardDescription className={`text-sm mt-1 grid gap-y-1 ${isGridLayout ? "grid-cols-1" : "grid-cols-2 gap-x-4"}`}> {server.name}
</CardTitle>
<CardDescription
className={`text-sm mt-1 grid gap-y-1 ${
isGridLayout
? "grid-cols-1"
: "grid-cols-2 gap-x-4"
}`}
>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<MonitorCog className="h-4 w-4 text-muted-foreground" /> <MonitorCog className="h-4 w-4 text-muted-foreground" />
<span><b>OS:</b> {server.os || '-'}</span> <span>
<b>OS:</b> {server.os || "-"}
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<FileDigit className="h-4 w-4 text-muted-foreground" /> <FileDigit className="h-4 w-4 text-muted-foreground" />
<span><b>IP:</b> {server.ip || 'Nicht angegeben'}</span> <span>
<b>IP:</b> {server.ip || "Not set"}
</span>
</div> </div>
<div className="col-span-full pt-2 pb-2"> <div className="col-span-full pt-2 pb-2">
@@ -378,150 +529,197 @@ export default function Dashboard() {
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<Cpu className="h-4 w-4 text-muted-foreground" /> <Cpu className="h-4 w-4 text-muted-foreground" />
<span><b>CPU:</b> {server.cpu || '-'}</span> <span>
<b>CPU:</b> {server.cpu || "-"}
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<Microchip className="h-4 w-4 text-muted-foreground" /> <Microchip className="h-4 w-4 text-muted-foreground" />
<span><b>GPU:</b> {server.gpu || '-'}</span> <span>
<b>GPU:</b> {server.gpu || "-"}
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<MemoryStick className="h-4 w-4 text-muted-foreground" /> <MemoryStick className="h-4 w-4 text-muted-foreground" />
<span><b>RAM:</b> {server.ram || '-'}</span> <span>
<b>RAM:</b> {server.ram || "-"}
</span>
</div> </div>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
<HardDrive className="h-4 w-4 text-muted-foreground" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
<span><b>Disk:</b> {server.disk || '-'}</span> <span>
<b>Disk:</b> {server.disk || "-"}
</span>
</div> </div>
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="flex flex-col items-end justify-start space-y-2 w-[405px]"> <div className="flex flex-col items-end justify-start space-y-2 w-[405px]">
<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">
{server.url && ( {server.url && (
<Button <Button
variant="outline" variant="outline"
className="gap-2 w-full" className="gap-2 w-full"
onClick={() => window.open(server.url, "_blank")} onClick={() =>
> window.open(server.url, "_blank")
}
>
<Link className="h-4 w-4" /> <Link className="h-4 w-4" />
Open Management URL Open Management URL
</Button>
)}
</div>
<Button
variant="destructive"
size="icon"
className="w-10"
onClick={() => deleteApplication(server.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
className="w-10"
onClick={() => openEditDialog(server)}
>
<Pencil className="h-4 w-4" />
</Button> </Button>
</AlertDialogTrigger> )}
<AlertDialogContent> </div>
<AlertDialogHeader> <div className="flex flex-col gap-2">
<AlertDialogTitle>Edit Server</AlertDialogTitle> <Button
<AlertDialogDescription> variant="destructive"
<Tabs defaultValue="general" className="w-full"> size="icon"
<TabsList className="w-full"> className="h-9 w-9"
<TabsTrigger value="general">General</TabsTrigger> onClick={() => deleteApplication(server.id)}
<TabsTrigger value="hardware">Hardware</TabsTrigger> >
</TabsList> <Trash2 className="h-4 w-4" />
<TabsContent value="general"> </Button>
<div className="space-y-4 pt-4"> <AlertDialog>
<div className="grid w-full items-center gap-1.5"> <AlertDialogTrigger asChild>
<Label htmlFor="editOs">Operating System</Label> <Button
<Select size="icon"
value={editOs} className="h-9 w-9"
onValueChange={setEditOs} onClick={() => openEditDialog(server)}
> >
<SelectTrigger className="w-full"> <Pencil className="h-4 w-4" />
<SelectValue placeholder="Select OS" /> </Button>
</SelectTrigger> </AlertDialogTrigger>
<SelectContent> <AlertDialogContent>
<SelectItem value="Windows">Windows</SelectItem> <AlertDialogHeader>
<SelectItem value="Linux">Linux</SelectItem> <AlertDialogTitle>
<SelectItem value="MacOS">MacOS</SelectItem> Edit Server
</SelectContent> </AlertDialogTitle>
</Select> <AlertDialogDescription>
</div> <Tabs
<div className="grid w-full items-center gap-1.5"> defaultValue="general"
<Label htmlFor="editIp">IP Adress</Label> className="w-full"
<Input >
id="editIp" <TabsList className="w-full">
type="text" <TabsTrigger value="general">
placeholder="e.g. 192.168.100.2" General
value={editIp} </TabsTrigger>
onChange={(e) => setEditIp(e.target.value)} <TabsTrigger value="hardware">
/> Hardware
</div> </TabsTrigger>
<div className="grid w-full items-center gap-1.5"> </TabsList>
<Label htmlFor="editUrl">Management URL</Label> <TabsContent value="general">
<Input <div className="space-y-4 pt-4">
id="editUrl" <div className="grid w-full items-center gap-1.5">
type="text" <Label htmlFor="editOs">
placeholder="e.g. https://proxmox.server1.com" Operating System
value={editUrl} </Label>
onChange={(e) => setEditUrl(e.target.value)} <Select
/> value={editOs}
</div> onValueChange={setEditOs}
</div> >
</TabsContent> <SelectTrigger className="w-full">
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Windows">
Windows
</SelectItem>
<SelectItem value="Linux">
Linux
</SelectItem>
<SelectItem value="MacOS">
MacOS
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editIp">
IP Adress
</Label>
<Input
id="editIp"
type="text"
placeholder="e.g. 192.168.100.2"
value={editIp}
onChange={(e) =>
setEditIp(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editUrl">
Management URL
</Label>
<Input
id="editUrl"
type="text"
placeholder="e.g. https://proxmox.server1.com"
value={editUrl}
onChange={(e) =>
setEditUrl(e.target.value)
}
/>
</div>
</div>
</TabsContent>
<TabsContent value="hardware"> <TabsContent value="hardware">
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu">CPU</Label> <Label htmlFor="editCpu">CPU</Label>
<Input <Input
id="editCpu" id="editCpu"
value={editCpu} value={editCpu}
onChange={(e) => setEditCpu(e.target.value)} onChange={(e) =>
/> setEditCpu(e.target.value)
</div> }
<div className="grid w-full items-center gap-1.5"> />
<Label htmlFor="editGpu">GPU</Label> </div>
<Input <div className="grid w-full items-center gap-1.5">
id="editGpu" <Label htmlFor="editGpu">GPU</Label>
value={editGpu} <Input
onChange={(e) => setEditGpu(e.target.value)} id="editGpu"
/> value={editGpu}
</div> onChange={(e) =>
<div className="grid w-full items-center gap-1.5"> setEditGpu(e.target.value)
<Label htmlFor="editRam">RAM</Label> }
<Input />
id="editRam" </div>
value={editRam} <div className="grid w-full items-center gap-1.5">
onChange={(e) => setEditRam(e.target.value)} <Label htmlFor="editRam">RAM</Label>
/> <Input
</div> id="editRam"
<div className="grid w-full items-center gap-1.5"> value={editRam}
<Label htmlFor="editDisk">Disk</Label> onChange={(e) =>
<Input setEditRam(e.target.value)
id="editDisk" }
value={editDisk} />
onChange={(e) => setEditDisk(e.target.value)} </div>
/> <div className="grid w-full items-center gap-1.5">
</div> <Label htmlFor="editDisk">
</div> Disk
</TabsContent> </Label>
</Tabs> <Input
</AlertDialogDescription> id="editDisk"
</AlertDialogHeader> value={editDisk}
<AlertDialogFooter> onChange={(e) =>
<AlertDialogCancel>Cancel</AlertDialogCancel> setEditDisk(e.target.value)
<Button onClick={edit}>Save</Button> }
</AlertDialogFooter> />
</AlertDialogContent> </div>
</AlertDialog> </div>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -529,31 +727,42 @@ export default function Dashboard() {
</Card> </Card>
))} ))}
</div> </div>
: ) : (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<div className='inline-block' role='status' aria-label='loading'> <div className="inline-block" role="status" aria-label="loading">
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'> <svg
<g clip-path='url(#clip0_9023_61563)'> className="w-6 h-6 stroke-white animate-spin "
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_9023_61563)">
<path
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
stroke="stroke-current"
stroke-width="1.4"
stroke-linecap="round"
className="my-path"
></path>
</g> </g>
<defs> <defs>
<clipPath id='clip0_9023_61563'> <clipPath id="clip0_9023_61563">
<rect width='24' height='24' fill='white'></rect> <rect width="24" height="24" fill="white"></rect>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div>
</div> </div>
} </div>
<div className="pt-4"> )}
<div className="pt-4 pb-4">
<Pagination> <Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
href="#"
onClick={handlePrevious} onClick={handlePrevious}
isActive={currentPage > 1} isActive={currentPage > 1}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
/> />
</PaginationItem> </PaginationItem>
@@ -563,9 +772,9 @@ export default function Dashboard() {
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext
href="#"
onClick={handleNext} onClick={handleNext}
isActive={currentPage < maxPage} isActive={currentPage < maxPage}
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
/> />
</PaginationItem> </PaginationItem>
</PaginationContent> </PaginationContent>
@@ -574,5 +783,5 @@ export default function Dashboard() {
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) );
} }

View File

@@ -12,7 +12,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Card, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { import {
Select, Select,
@@ -21,15 +21,121 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { 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 } from "lucide-react";
export default function Settings() { export default function Settings() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const [confirmPassword, setConfirmPassword] = useState<string>("")
const [oldPassword, setOldPassword] = useState<string>("")
const [emailError, setEmailError] = useState<string>("")
const [passwordError, setPasswordError] = useState<string>("")
const [emailErrorVisible, setEmailErrorVisible] = useState<boolean>(false)
const [passwordErrorVisible, setPasswordErrorVisible] = useState<boolean>(false)
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
const changeEmail = async () => {
setEmailErrorVisible(false);
setEmailSuccess(false);
setEmailError("");
if (!email) {
setEmailError("Email is required");
setEmailErrorVisible(true);
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}
, 3000);
return;
}
try {
await axios.post('/api/auth/edit_email', {
newEmail: email,
jwtToken: Cookies.get('token')
});
setEmailSuccess(true);
setEmail("");
setTimeout(() => {
setEmailSuccess(false);
}, 3000);
} catch (error: any) {
setEmailError(error.response.data.error);
setEmailErrorVisible(true);
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}, 3000);
}
}
const changePassword = async () => {
try {
if (password !== confirmPassword) {
setPasswordError("Passwords do not match");
setPasswordErrorVisible(true);
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
}
if (!oldPassword || !password || !confirmPassword) {
setPasswordError("All fields are required");
setPasswordErrorVisible(true);
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
}
const response = await axios.post('/api/auth/edit_password', {
oldPassword: oldPassword,
newPassword: password,
jwtToken: Cookies.get('token')
});
if (response.status === 200) {
setPasswordSuccess(true);
setPassword("");
setOldPassword("");
setConfirmPassword("");
setTimeout(() => {
setPasswordSuccess(false);
}, 3000);
}
} catch (error: any) {
setPasswordErrorVisible(true);
setPasswordError(error.response.data.error);
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
}
}
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4"> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
@@ -50,32 +156,142 @@ export default function Settings() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<div className="pl-4 pr-4"> <div className="p-6">
<span className="text-2xl font-semibold">Settings</span> <div className="pb-4">
<div className="pt-4"> <span className="text-3xl font-bold">Settings</span>
<Card className="w-full mb-4 relative"> </div>
<CardHeader> <div className="grid gap-6">
<span className="text-xl font-bold">Theme</span> <Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<Select <CardHeader className="bg-muted/10 px-6 py-4 border-b">
value={theme} <div className="flex items-center gap-2">
onValueChange={(value: string) => setTheme(value)} <User className="h-5 w-5 text-primary" />
> <h2 className="text-xl font-semibold">User Settings</h2>
<SelectTrigger className="w-full [&_svg]:hidden"> </div>
<SelectValue>
{(theme ?? 'system').charAt(0).toUpperCase() + (theme ?? 'system').slice(1)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</CardHeader> </CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Manage your user settings here. You can change your email, password, and other account settings.
</div>
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">Change Email</h3>
</div>
{emailErrorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{emailError}</AlertDescription>
</Alert>
)}
{emailSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
<Check className="h-4 w-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Email changed successfully.</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Input
type="email"
placeholder="Enter new email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11"
/>
<Button onClick={changeEmail} className="w-full h-11">
Change Email
</Button>
</div>
</div>
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">Change Password</h3>
</div>
{passwordErrorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
{passwordSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
<Check className="h-4 w-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Password changed successfully.</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Input
type="password"
placeholder="Enter old password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="h-11"
/>
<Input
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11"
/>
<Input
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-11"
/>
<Button onClick={changePassword} className="w-full h-11">
Change Password
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">Theme Settings</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Select a theme for the application. You can choose between light, dark, or system theme.
</div>
<div className="max-w-md">
<Select value={theme} onValueChange={(value: string) => setTheme(value)}>
<SelectTrigger className="w-full h-11">
<SelectValue>
{(theme ?? "system").charAt(0).toUpperCase() + (theme ?? "system").slice(1)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
); )
} }

View File

@@ -0,0 +1,296 @@
import { AppSidebar } from "@/components/app-sidebar";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader } from "@/components/ui/card";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
const timeFormats = {
1: (timestamp: string) =>
new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
2: (timestamp: string) => {
const start = new Date(timestamp);
const end = new Date(start.getTime() + 3 * 60 * 60 * 1000);
return `${start.toLocaleDateString([], { day: '2-digit', month: 'short' })}
${start.getHours().toString().padStart(2, '0')}:00 -
${end.getHours().toString().padStart(2, '0')}:00`;
},
3: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], {
day: '2-digit',
month: 'short'
})
};
const minBoxWidths = {
1: 24,
2: 24,
3: 24
};
interface UptimeData {
appName: string;
appId: number;
uptimeSummary: {
timestamp: string;
missing: boolean;
online: boolean | null;
}[];
}
interface PaginationData {
currentPage: number;
totalPages: number;
totalItems: number;
}
export default function Uptime() {
const [data, setData] = useState<UptimeData[]>([]);
const [timespan, setTimespan] = useState<1 | 2 | 3>(1);
const [pagination, setPagination] = useState<PaginationData>({
currentPage: 1,
totalPages: 1,
totalItems: 0
});
const [isLoading, setIsLoading] = useState(false);
const getData = async (selectedTimespan: number, page: number) => {
setIsLoading(true);
try {
const response = await axios.post<{
data: UptimeData[];
pagination: PaginationData;
}>("/api/applications/uptime", {
timespan: selectedTimespan,
page
});
setData(response.data.data);
setPagination(response.data.pagination);
} catch (error) {
console.error("Error:", error);
setData([]);
setPagination({
currentPage: 1,
totalPages: 1,
totalItems: 0
});
} finally {
setIsLoading(false);
}
};
const handlePrevious = () => {
const newPage = Math.max(1, pagination.currentPage - 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
};
const handleNext = () => {
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
};
useEffect(() => {
getData(timespan, 1);
}, [timespan]);
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>/</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>My Infrastructure</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Uptime</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-3xl font-bold">Uptime</span>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 30 minutes</SelectItem>
<SelectItem value="2">Last 7 days</SelectItem>
<SelectItem value="3">Last 30 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="pt-4 space-y-4">
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : (
data.map((app) => {
const reversedSummary = [...app.uptimeSummary].reverse();
const startTime = reversedSummary[0]?.timestamp;
const endTime = reversedSummary[reversedSummary.length - 1]?.timestamp;
return (
<Card key={app.appId}>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold">{app.appName}</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>{startTime ? timeFormats[timespan](startTime) : ""}</span>
<span>{endTime ? timeFormats[timespan](endTime) : ""}</span>
</div>
<Tooltip.Provider>
<div
className="grid gap-0.5 w-full pb-2"
style={{
gridTemplateColumns: `repeat(auto-fit, minmax(${minBoxWidths[timespan]}px, 1fr))`
}}
>
{reversedSummary.map((entry) => (
<Tooltip.Root key={entry.timestamp}>
<Tooltip.Trigger asChild>
<div
className={`h-8 w-full rounded-sm border transition-colors ${
entry.missing
? "bg-gray-300 border-gray-400"
: entry.online
? "bg-green-500 border-green-600"
: "bg-red-500 border-red-600"
}`}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="rounded bg-gray-900 px-2 py-1 text-white text-xs shadow-lg"
side="top"
>
<div className="flex flex-col gap-1">
<p className="font-medium">
{timespan === 2 ? (
timeFormats[2](entry.timestamp)
) : (
new Date(entry.timestamp).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: timespan === 3 ? undefined : '2-digit',
hour12: false
})
)}
</p>
<p>
{entry.missing
? "No data"
: entry.online
? "Online"
: "Offline"}
</p>
</div>
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
))}
</div>
</Tooltip.Provider>
</div>
</div>
</CardHeader>
</Card>
);
})
)}
</div>
{pagination.totalItems > 0 && !isLoading && (
<div className="pt-4 pb-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={handlePrevious}
aria-disabled={pagination.currentPage === 1 || isLoading}
className={
pagination.currentPage === 1 || isLoading
? "opacity-50 cursor-not-allowed"
: "hover:cursor-pointer"
}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink isActive>{pagination.currentPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNext}
aria-disabled={pagination.currentPage === pagination.totalPages || isLoading}
className={
pagination.currentPage === pagination.totalPages || isLoading
? "opacity-50 cursor-not-allowed"
: "hover:cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";
import Uptime from "./Uptime";
import axios from "axios";
export default function DashboardPage() {
const router = useRouter();
const [isAuthChecked, setIsAuthChecked] = useState(false);
const [isValid, setIsValid] = useState(false);
useEffect(() => {
const token = Cookies.get("token");
if (!token) {
router.push("/");
} else {
const checkToken = async () => {
try {
const response = await axios.post("/api/auth/validate", {
token: token,
});
if (response.status === 200) {
setIsValid(true);
}
} catch (error: any) {
Cookies.remove("token");
router.push("/");
}
}
checkToken();
}
setIsAuthChecked(true);
}, [router]);
if (!isAuthChecked) {
return (
<div className="flex items-center justify-center h-screen">
<div className='inline-block' role='status' aria-label='loading'>
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clip-path='url(#clip0_9023_61563)'>
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
</g>
<defs>
<clipPath id='clip0_9023_61563'>
<rect width='24' height='24' fill='white'></rect>
</clipPath>
</defs>
</svg>
<span className='sr-only'>Loading...</span>
</div>
</div>
)
}
return isValid ? <Uptime /> : null;
}

View File

@@ -1,108 +1,148 @@
"use client"; "use client"
import type React from "react"
import { useState, useEffect } from "react"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import axios from "axios"
import { AlertCircle, KeyRound, Mail, User } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { AlertCircle } from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { useState, useEffect } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";
import axios from "axios";
export default function Home() { export default function Home() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState("")
const [password, setPassword] = useState(''); const [password, setPassword] = useState("")
const router = useRouter(); const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState(''); const router = useRouter()
const [errorVisible, setErrorVisible] = useState(false); const [error, setError] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => { useEffect(() => {
const token = Cookies.get('token'); const token = Cookies.get("token")
if (token) { if (token) {
router.push('/dashboard'); router.push("/dashboard")
} }
}, [router]); }, [router])
interface LoginResponse { interface LoginResponse {
token: string; token: string
} }
const login = async () => { const login = async () => {
try { if (!username || !password) {
const response = await axios.post('/api/auth/login', { username, password }); setError("Please enter both email and password")
const { token } = response.data as LoginResponse;
Cookies.set('token', token);
router.push('/dashboard');
} catch (error: any) {
setError(error.response.data.error);
setErrorVisible(true) setErrorVisible(true)
return
}
try {
setIsLoading(true)
const response = await axios.post("/api/auth/login", { username, password })
const { token } = response.data as LoginResponse
const cookieOptions = rememberMe ? { expires: 7 } : {}
Cookies.set("token", token, cookieOptions)
router.push("/dashboard")
} catch (error: any) {
setError(error.response?.data?.error || "Login failed. Please try again.")
setErrorVisible(true)
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
login()
} }
} }
return ( return (
<div className="flex flex-col min-h-screen items-center justify-center gap-6 "> <div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
<Card className="w-1/3"> <div className="w-full max-w-md space-y-8">
<CardHeader> <div className="text-center space-y-2">
<CardTitle className="text-2xl">Login</CardTitle> <div className="flex justify-center">
<CardDescription> <div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
Enter your Login data of the compose.yml file below to access <KeyRound className="h-8 w-8 text-primary" />
</CardDescription>
</CardHeader>
<CardContent>
{errorVisible && (
<>
<div className="pb-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error}
</AlertDescription>
</Alert>
</div>
</>
)}
<div>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="mail@example.com"
required
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input id="password" type="password" required placeholder="* * * * * * *" onChange={(e) => setPassword(e.target.value)}/>
</div>
<Button className="w-full" onClick={login}>
Login
</Button>
</div> </div>
</div> </div>
</CardContent> <h1 className="text-4xl font-bold tracking-tight text-foreground">CoreControl</h1>
</Card> <p className="text-muted-foreground">Sign in to access your dashboard</p>
</div>
<Card className="border-muted/40 shadow-lg">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-semibold">Login</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{errorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50 slide-in-from-top-5">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Authentication Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
</Label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="mail@example.com"
className="pl-10"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-medium">
Password
</Label>
</div>
<div className="relative">
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
required
/>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button className="w-full" onClick={login} disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</CardFooter>
</Card>
</div>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import Image from "next/image" import Image from "next/image"
import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network } from "lucide-react" import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network, Activity } from "lucide-react"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -18,8 +18,8 @@ import { Button } from "@/components/ui/button"
import Link from "next/link" import Link from "next/link"
import Cookies from "js-cookie" import Cookies from "js-cookie"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import packageJson from "@/package.json"
// Typdefinitionen
interface NavItem { interface NavItem {
title: string title: string
icon?: React.ComponentType<any> icon?: React.ComponentType<any>
@@ -50,6 +50,11 @@ const data: { navMain: NavItem[] } = {
icon: AppWindow, icon: AppWindow,
url: "/dashboard/applications", url: "/dashboard/applications",
}, },
{
title: "Uptime",
icon: Activity,
url: "/dashboard/uptime",
},
{ {
title: "Network", title: "Network",
icon: Network, icon: Network,
@@ -83,7 +88,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<Image src="/logo.png" width={48} height={48} alt="Logo"/> <Image src="/logo.png" width={48} height={48} alt="Logo"/>
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">CoreControl</span> <span className="font-semibold">CoreControl</span>
<span className="">v0.0.1</span> <span className="">v{packageJson.version}</span>
</div> </div>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -4,11 +4,8 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
LOGIN_EMAIL: "mail@example.com" JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
LOGIN_PASSWORD: "SecretPassword" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
depends_on: depends_on:
- db - db
- agent - agent
@@ -16,7 +13,7 @@ services:
agent: agent:
image: haedlessdev/corecontrol-agent:latest image: haedlessdev/corecontrol-agent:latest
environment: environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db: db:
image: postgres:17 image: postgres:17

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

27
docs/README.md Normal file
View File

@@ -0,0 +1,27 @@
---
icon: gear
layout:
title:
visible: true
description:
visible: true
tableOfContents:
visible: true
outline:
visible: false
pagination:
visible: true
---
# CoreControl
<figure><img src=".gitbook/assets/image.png" alt=""><figcaption></figcaption></figure>
CoreControl is the only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
## Features
* Dashboard: A clear screen with all the important information about your servers (WIP)
* Servers: This allows you to add all your servers (including Hardware Information), with Quicklinks to their Management Panels
* Applications: Add all your self-hosted services to a clear list and track their up and down time
* Networks: Generate visually stunning network flowcharts with ease.

4
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,4 @@
# Table of contents
* [CoreControl](README.md)
* [Installation](installation.md)

56
docs/installation.md Normal file
View File

@@ -0,0 +1,56 @@
---
icon: down
---
# Installation
To install the application using Docker Compose, first, ensure that Docker and Docker Compose are installed on your system.&#x20;
You can then simply install and start the following Docker compose. Remember that you have to generate a JWT\_SECRET beforehand.
```yaml
services:
web:
image: haedlessdev/corecontrol:latest
ports:
- "3000:3000"
environment:
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db:
image: postgres:17
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
```
Now start the application:
```sh
docker compose up -d
```
**The default login is:**
E-Mail: [admin@example.com](mailto:admin@example.com)\
Password: admin
_Be sure to set your own password and customize the e-mail, otherwise this poses a security risk!_

827
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.1.0", "version": "0.0.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -11,6 +11,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",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-alert-dialog": "^1.1.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",
@@ -21,18 +22,20 @@
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@types/axios": "^0.9.36", "@types/axios": "^0.9.36",
"@types/bcrypt": "^5.0.2",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@xyflow/react": "^12.5.5", "@xyflow/react": "^12.5.5",
"axios": "^1.8.4", "axios": "^1.8.4",
"bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"next": "15.3.0", "next": "15.3.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.14.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "uptime_history" (
"id" SERIAL NOT NULL,
"applicationId" INTEGER NOT NULL DEFAULT 1,
"online" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uptime_history_pkey" PRIMARY KEY ("id")
);

View File

@@ -25,6 +25,13 @@ model application {
online Boolean @default(true) online Boolean @default(true)
} }
model uptime_history {
id Int @id @default(autoincrement())
applicationId Int @default(1)
online Boolean @default(true)
createdAt DateTime @default(now())
}
model server { model server {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
@@ -38,6 +45,12 @@ model server {
} }
model settings { model settings {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uptime_checks Boolean @default(true) uptime_checks Boolean @default(true)
}
model user {
id String @id @default(uuid())
email String @unique
password String
} }

BIN
public/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB