83 Commits

Author SHA1 Message Date
headlessdev
f024b0166f v0.0.5 uptime_fix->main
v0.0.5
2025-04-17 14:53:40 +02:00
headlessdev
d6889a27b5 Fix: Update online status check to account for HTTP 405 response 2025-04-17 14:41:50 +02:00
headlessdev
edbc72a7c9 Enhance application status check logging and error handling 2025-04-17 14:18:50 +02:00
headlessdev
7e82b42b29 HOTFIX: Agent context deadline fix 2025-04-17 11:36:35 +02:00
headlessdev
f5c835b5d9 Hotfix Docker file 'bcrypt' error 2025-04-16 14:55:09 +02:00
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 3277 additions and 797 deletions

View File

@@ -4,32 +4,44 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
COPY ./prisma ./prisma
# Install all dependencies (including devDependencies)
RUN npm install
# Generate Prisma client
RUN npx prisma generate
# Build the application
COPY . .
RUN npm run build
# Production Stage
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV production
# Copy package files
COPY package.json package-lock.json* ./
RUN npm install --production
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
# Copy node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Remove dev dependencies
RUN npm prune --production
# Copy Prisma files
COPY --from=builder /app/prisma ./prisma
# Copy built application
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js* ./
EXPOSE 3000
CMD ["npm", "start"]
# Run migrations and start
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.
<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
- 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
- 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
- [ ] Edit Applications, Applications searchbar
- [ ] Customizable Dashboard
- [X] Edit Applications, Applications searchbar
- [X] Uptime History
- [ ] Notifications
- [ ] Uptime History
- [ ] Simple Server Monitoring
- [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more)
@@ -32,11 +55,8 @@ services:
ports:
- "3000:3000"
environment:
LOGIN_EMAIL: "mail@example.com"
LOGIN_PASSWORD: "SecretPassword"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
@@ -44,7 +64,7 @@ services:
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db:
image: postgres:17
@@ -60,6 +80,10 @@ volumes:
postgres_data:
```
#### Default Login
__E-Mail:__ admin@example.com\
__Password:__ admin
## Tech Stack & Credits
The application is build with:
@@ -69,8 +93,13 @@ The application is build with:
- PostgreSQL with [Prisma ORM](https://www.prisma.io/)
- Icons by [Lucide](https://lucide.dev/)
- Flowcharts by [React Flow](https://reactflow.dev/)
- Application icons by [selfh.st/icons](selfh.st/icons)
- 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
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()
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()
client := &http.Client{
Timeout: 4 * time.Second,
}
for range ticker.C {
for now := range ticker.C {
if now.Second()%10 != 0 {
continue
}
apps := getApplications(db)
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 {
rows, err := db.Query(`
SELECT id, "publicURL", online
@@ -73,29 +102,70 @@ func getApplications(db *sql.DB) []Application {
}
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
for _, app := range apps {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
fmt.Printf("Start checking %d applications at %v\n", len(apps), time.Now())
req, err := http.NewRequestWithContext(ctx, "HEAD", app.PublicURL, nil)
for i, app := range apps {
logPrefix := fmt.Sprintf("[App %d/%d URL: %s]", i+1, len(apps), app.PublicURL)
fmt.Printf("%s Starting check\n", logPrefix)
// HTTP Check
startHTTP := time.Now()
httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second)
defer httpCancel()
req, err := http.NewRequestWithContext(httpCtx, "HEAD", app.PublicURL, nil)
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
continue
}
resp, err := client.Do(req)
isOnline := false
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
isOnline = true
httpDuration := time.Since(startHTTP)
// Log HTTP details
if err != nil {
fmt.Printf("%s HTTP error after %v: %v\n", logPrefix, httpDuration, err)
} else {
fmt.Printf("%s HTTP %d after %v (ContentLength: %d)\n",
logPrefix, resp.StatusCode, httpDuration, resp.ContentLength)
resp.Body.Close() // Important to prevent leaks
}
_, err = db.ExecContext(ctx,
"UPDATE application SET online = $1 WHERE id = $2",
isOnline := err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 || resp.StatusCode == 405
// Database Update
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dbCancel()
startUpdate := time.Now()
updateRes, err := db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`,
isOnline,
app.ID,
)
updateDuration := time.Since(startUpdate)
if err != nil {
fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
fmt.Printf("%s UPDATE failed after %v: %v\n", logPrefix, updateDuration, err)
} else {
affected, _ := updateRes.RowsAffected()
fmt.Printf("%s UPDATE OK (%d rows) after %v\n", logPrefix, affected, updateDuration)
}
// History Insert
startInsert := time.Now()
insertRes, err := db.ExecContext(dbCtx,
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
app.ID,
isOnline,
)
insertDuration := time.Since(startInsert)
if err != nil {
fmt.Printf("%s INSERT failed after %v: %v\n", logPrefix, insertDuration, err)
} else {
inserted, _ := insertRes.RowsAffected()
fmt.Printf("%s INSERT OK (%d rows) after %v\n", logPrefix, inserted, insertDuration)
}
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,16 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface GetRequest {
page: number;
page?: number;
ITEMS_PER_PAGE?: number;
}
const ITEMS_PER_PAGE = 5;
export async function POST(request: NextRequest) {
try {
const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1);
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
const servers = await prisma.server.findMany({
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 {
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"; // Korrekter Import
import { Card, CardHeader } from "@/components/ui/card";
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface StatsResponse {
serverCount: number;
applicationCount: number;
onlineApplicationsCount: number;
serverCount: number
applicationCount: number
onlineApplicationsCount: number
}
export default function Dashboard() {
const [serverCount, setServerCount] = useState<number>(0);
const [applicationCount, setApplicationCount] = useState<number>(0);
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0);
const [serverCount, setServerCount] = useState<number>(0)
const [applicationCount, setApplicationCount] = useState<number>(0)
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
const getStats = async () => {
try {
const response = await axios.post<StatsResponse>('/api/dashboard/get', {});
setServerCount(response.data.serverCount);
setApplicationCount(response.data.applicationCount);
setOnlineApplicationsCount(response.data.onlineApplicationsCount);
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
setServerCount(response.data.serverCount)
setApplicationCount(response.data.applicationCount)
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) {
console.log("Axios error:", error.response?.data);
console.log("Axios error:", error.response?.data)
}
};
}
useEffect(() => {
getStats();
}, []);
getStats()
}, [])
return (
<SidebarProvider>
<AppSidebar />
<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">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>
/
</BreadcrumbPage>
<BreadcrumbPage>/</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
@@ -66,51 +65,105 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">{serverCount}</span>
<span className="text-md">Servers</span>
</div>
<div className="p-6">
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Servers</CardTitle>
<Server className="h-6 w-6 text-rose-500" />
</div>
<CardDescription>Manage your server infrastructure</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
</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 className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">{applicationCount}</span>
<span className="text-md">Applications</span>
</div>
<Card className="overflow-hidden border-t-4 border-t-amber-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">Applications</CardTitle>
<Layers className="h-6 w-6 text-amber-500" />
</div>
<CardDescription>Manage your deployed applications</CardDescription>
</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 className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">
<Card className="overflow-hidden border-t-4 border-t-emerald-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">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}
</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 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>
</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>
</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>
</SidebarInset>
</SidebarProvider>
);
}
)
}

View File

@@ -16,7 +16,16 @@ import {
SidebarTrigger,
} from "@/components/ui/sidebar";
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 {
Card,
CardContent,
@@ -58,6 +67,12 @@ import {
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import axios from "axios";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface Application {
id: number;
@@ -89,6 +104,15 @@ export default function Dashboard() {
const [publicURL, setPublicURL] = useState<string>("");
const [localURL, setLocalURL] = useState<string>("");
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 [maxPage, setMaxPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
@@ -97,9 +121,12 @@ export default function Dashboard() {
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [searchTerm, setSearchTerm] = useState<string>("");
const [isSearching, setIsSearching] = useState<boolean>(false);
useEffect(() => {
const savedLayout = Cookies.get('layoutPreference-app');
const layout_bool = savedLayout === 'grid';
const savedLayout = Cookies.get("layoutPreference-app");
const layout_bool = savedLayout === "grid";
setIsGridLayout(layout_bool);
setItemsPerPage(layout_bool ? 15 : 5);
}, []);
@@ -107,35 +134,35 @@ export default function Dashboard() {
const toggleLayout = () => {
const newLayout = !isGridLayout;
setIsGridLayout(newLayout);
Cookies.set('layoutPreference-app', newLayout ? 'grid' : 'standard', {
Cookies.set("layoutPreference-app", newLayout ? "grid" : "standard", {
expires: 365,
path: '/',
sameSite: 'strict'
path: "/",
sameSite: "strict",
});
setItemsPerPage(newLayout ? 15 : 5);
};
const add = async () => {
try {
await axios.post('/api/applications/add', {
name,
description,
icon,
publicURL,
await axios.post("/api/applications/add", {
name,
description,
icon,
publicURL,
localURL,
serverId
serverId,
});
getApplications();
} catch (error: any) {
console.log(error.response?.data);
console.log(error.response?.data);
}
}
};
const getApplications = async () => {
try {
setLoading(true);
const response = await axios.post<ApplicationsResponse>(
'/api/applications/get',
"/api/applications/get",
{ page: currentPage, ITEMS_PER_PAGE: itemsPerPage }
);
setApplications(response.data.applications);
@@ -145,29 +172,95 @@ export default function Dashboard() {
} catch (error: any) {
console.log(error.response?.data);
}
}
};
useEffect(() => {
getApplications();
}, [currentPage, itemsPerPage]);
const handlePrevious = () => setCurrentPage(prev => Math.max(1, prev - 1));
const handleNext = () => setCurrentPage(prev => Math.min(maxPage, prev + 1));
const handlePrevious = () => setCurrentPage((prev) => Math.max(1, prev - 1));
const handleNext = () =>
setCurrentPage((prev) => Math.min(maxPage, prev + 1));
const deleteApplication = async (id: number) => {
try {
await axios.post('/api/applications/delete', { id });
await axios.post("/api/applications/delete", { id });
getApplications();
} catch (error: any) {
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 (
<SidebarProvider>
<AppSidebar />
<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">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -188,20 +281,28 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<div className="p-6">
<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">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
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>
{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>
<AlertDialogTrigger asChild>
@@ -214,163 +315,390 @@ export default function Dashboard() {
<AlertDialogTitle>Add an 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" onChange={(e) => setName(e.target.value)}/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Server</Label>
<Select onValueChange={(v) => setServerId(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" 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>
<Input placeholder="https://example.com/icon.png" onChange={(e) => setIcon(e.target.value)}/>
</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 className="grid w-full items-center gap-1.5">
<Label>Name</Label>
<Input
placeholder="e.g. Portainer"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Server</Label>
<Select
onValueChange={(v) => setServerId(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"
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>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={add} disabled={!name || !publicURL || !serverId}>
<AlertDialogAction
onClick={add}
disabled={!name || !publicURL || !serverId}
>
Add
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</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 />
{!loading ?
<div className={isGridLayout ?
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" :
"space-y-4"}>
{!loading ? (
<div
className={
isGridLayout
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
: "space-y-4"
}
>
{applications.map((app) => (
<Card
key={app.id}
className={isGridLayout ? "h-full flex flex-col justify-between relative" : "w-full mb-4 relative"}
>
<CardHeader>
<div className="absolute top-2 right-2">
<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"}
>
<div className={`w-2 h-2 rounded-full ${app.online ? "bg-green-500" : "bg-red-500"}`} />
</div>
</div>
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
{app.icon ? (
<img src={app.icon} alt={app.name} className="w-full h-full object-contain rounded-md" />
) : (
<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>
<Card
key={app.id}
className={
isGridLayout
? "h-full flex flex-col justify-between relative"
: "w-full mb-4 relative"
}
>
<CardHeader>
<div className="absolute top-2 right-2">
<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"}
>
<div
className={`w-2 h-2 rounded-full ${
app.online ? "bg-green-500" : "bg-red-500"
}`}
/>
</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
variant="outline"
className="gap-2 w-full"
onClick={() => window.open(app.publicURL, "_blank")}
>
<Link className="h-4 w-4" />
Open Public URL
</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 className="flex items-center justify-between w-full">
<div className="flex items-center">
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
{app.icon ? (
<img
src={app.icon}
alt={app.name}
className="w-full h-full object-contain rounded-md"
/>
) : (
<span className="text-gray-500 text-xs">Image</span>
)}
</div>
<Button
variant="destructive"
size="icon"
className="h-[72px] w-10"
onClick={() => deleteApplication(app.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<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
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.publicURL, "_blank")
}
>
<Link className="h-4 w-4" />
Open Public URL
</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>
</div>
</div>
</CardHeader>
</Card>
))}
</CardHeader>
</Card>
))}
</div>
:
<div className="flex items-center justify-center">
<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>
) : (
<div className="flex items-center justify-center">
<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>
</defs>
</svg>
<span className='sr-only'>Loading...</span>
</div>
<span className="sr-only">Loading...</span>
</div>
</div>
}
<div className="pt-4">
)}
<div className="pt-4 pb-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
<PaginationPrevious
onClick={handlePrevious}
isActive={currentPage > 1}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink isActive>{currentPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
<PaginationNext
onClick={handleNext}
isActive={currentPage < maxPage}
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
</PaginationContent>
@@ -379,5 +707,5 @@ export default function Dashboard() {
</div>
</SidebarInset>
</SidebarProvider>
)
}
);
}

View File

@@ -41,7 +41,7 @@ export default function Dashboard() {
<SidebarProvider>
<AppSidebar />
<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">
<SidebarTrigger className="-ml-1 dark:text-white" />
<Separator

View File

@@ -16,7 +16,20 @@ import {
SidebarTrigger,
} from "@/components/ui/sidebar";
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 {
Card,
CardContent,
@@ -62,7 +75,7 @@ import {
} from "@/components/ui/tooltip";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import axios from 'axios';
import axios from "axios";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface Server {
@@ -94,6 +107,7 @@ export default function Dashboard() {
const [currentPage, setCurrentPage] = useState<number>(1);
const [maxPage, setMaxPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(4);
const [servers, setServers] = useState<Server[]>([]);
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
@@ -108,73 +122,83 @@ export default function Dashboard() {
const [editRam, setEditRam] = useState<string>("");
const [editDisk, setEditDisk] = useState<string>("");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isSearching, setIsSearching] = useState<boolean>(false);
useEffect(() => {
const savedLayout = Cookies.get('layoutPreference-servers');
setIsGridLayout(savedLayout === 'grid');
const savedLayout = Cookies.get("layoutPreference-servers");
const layout_bool = savedLayout === "grid";
setIsGridLayout(layout_bool);
setItemsPerPage(layout_bool ? 6 : 4);
}, []);
const toggleLayout = () => {
const newLayout = !isGridLayout;
setIsGridLayout(newLayout);
Cookies.set('layoutPreference-servers', newLayout ? 'grid' : 'standard', {
Cookies.set("layoutPreference-servers", newLayout ? "grid" : "standard", {
expires: 365,
path: '/',
sameSite: 'strict'
path: "/",
sameSite: "strict",
});
setItemsPerPage(newLayout ? 6 : 4);
};
const add = async () => {
try {
await axios.post('/api/servers/add', {
name,
os,
ip,
url,
cpu,
gpu,
ram,
disk
await axios.post("/api/servers/add", {
name,
os,
ip,
url,
cpu,
gpu,
ram,
disk,
});
getServers();
} catch (error: any) {
console.log(error.response.data);
}
}
};
const getServers = async () => {
try {
setLoading(true);
const response = await axios.post<GetServersResponse>('/api/servers/get', {
page: currentPage
});
const response = await axios.post<GetServersResponse>(
"/api/servers/get",
{
page: currentPage,
ITEMS_PER_PAGE: itemsPerPage,
}
);
setServers(response.data.servers);
setMaxPage(response.data.maxPage);
setLoading(false);
} catch (error: any) {
console.log(error.response);
}
}
};
useEffect(() => {
getServers();
}, [currentPage]);
}, [currentPage, itemsPerPage]);
const handlePrevious = () => {
setCurrentPage(prev => Math.max(1, prev - 1));
}
setCurrentPage((prev) => Math.max(1, prev - 1));
};
const handleNext = () => {
setCurrentPage(prev => Math.min(maxPage, prev + 1));
}
setCurrentPage((prev) => Math.min(maxPage, prev + 1));
};
const deleteApplication = async (id: number) => {
try {
await axios.post('/api/servers/delete', { id });
await axios.post("/api/servers/delete", { id });
getServers();
} catch (error: any) {
console.log(error.response.data);
}
}
};
const openEditDialog = (server: Server) => {
setEditId(server.id);
@@ -190,9 +214,9 @@ export default function Dashboard() {
const edit = async () => {
if (!editId) return;
try {
await axios.put('/api/servers/edit', {
await axios.put("/api/servers/edit", {
id: editId,
name: editName,
os: editOs,
@@ -201,21 +225,48 @@ export default function Dashboard() {
cpu: editCpu,
gpu: editGpu,
ram: editRam,
disk: editDisk
disk: editDisk,
});
getServers();
setEditId(null);
} catch (error: any) {
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 (
<SidebarProvider>
<AppSidebar />
<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">
<div className="flex items-center gap-2 px-4">
<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>
@@ -235,15 +286,15 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<div className="p-6">
<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">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={toggleLayout}
>
@@ -255,9 +306,9 @@ export default function Dashboard() {
</Button>
</TooltipTrigger>
<TooltipContent>
{isGridLayout ?
"Switch to list view" :
"Switch to grid view"}
{isGridLayout
? "Switch to list view"
: "Switch to grid view"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -271,70 +322,144 @@ export default function Dashboard() {
<AlertDialogHeader>
<AlertDialogTitle>Add an server</AlertDialogTitle>
<AlertDialogDescription>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger>
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" type="text" placeholder="e.g. Server1" 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)}>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger>
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="e.g. Server1"
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">
<SelectValue placeholder="Select OS" />
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Windows">Windows</SelectItem>
<SelectItem value="Linux">Linux</SelectItem>
<SelectItem value="MacOS">MacOS</SelectItem>
<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="icon">IP Adress <span className="text-stone-600">(optional)</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>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="icon">
IP Adress{" "}
<span className="text-stone-600">
(optional)
</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>
<TooltipTrigger>
<Label htmlFor="publicURL">Management URL <span className="text-stone-600">(optional)</span></Label>
</TooltipTrigger>
<TooltipContent>
Link to a web interface (e.g. Proxmox or Portainer) with which the server can be managed
</TooltipContent>
<TooltipTrigger>
<Label htmlFor="publicURL">
Management URL{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
</TooltipTrigger>
<TooltipContent>
Link to a web interface (e.g. Proxmox or
Portainer) with which the server can be
managed
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Input id="publicURL" type="text" placeholder="e.g. https://proxmox.server1.com" onChange={(e) => setUrl(e.target.value)}/>
</TooltipProvider>
<Input
id="publicURL"
type="text"
placeholder="e.g. https://proxmox.server1.com"
onChange={(e) => setUrl(e.target.value)}
/>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="hardware">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">CPU <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)}/>
</TabsContent>
<TabsContent value="hardware">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">
CPU{" "}
<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 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>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -345,31 +470,57 @@ export default function Dashboard() {
</AlertDialog>
</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 />
{!loading ?
<div className={isGridLayout ?
"grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4" :
"space-y-4"}>
{!loading ? (
<div
className={
isGridLayout
? "grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4"
: "space-y-4"
}
>
{servers.map((server) => (
<Card
key={server.id}
className={isGridLayout ?
"h-full flex flex-col justify-between" :
"w-full mb-4"}
<Card
key={server.id}
className={
isGridLayout
? "h-full flex flex-col justify-between"
: "w-full mb-4"
}
>
<CardHeader>
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<div className="ml-4">
<CardTitle className="text-2xl font-bold">{server.name}</CardTitle>
<CardDescription className={`text-sm mt-1 grid gap-y-1 ${isGridLayout ? "grid-cols-1" : "grid-cols-2 gap-x-4"}`}>
<CardTitle className="text-2xl font-bold">
{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">
<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 className="flex items-center gap-2 text-foreground/80">
<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 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">
<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 className="flex items-center gap-2 text-foreground/80">
<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 className="flex items-center gap-2 text-foreground/80">
<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 className="flex items-center gap-2 text-foreground/80">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span><b>Disk:</b> {server.disk || '-'}</span>
<span>
<b>Disk:</b> {server.disk || "-"}
</span>
</div>
</CardDescription>
</div>
</div>
<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 flex-col space-y-2 flex-grow">
<div className="flex flex-col space-y-2 flex-grow">
{server.url && (
<Button
variant="outline"
<Button
variant="outline"
className="gap-2 w-full"
onClick={() => window.open(server.url, "_blank")}
>
onClick={() =>
window.open(server.url, "_blank")
}
>
<Link className="h-4 w-4" />
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>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit Server</AlertDialogTitle>
<AlertDialogDescription>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger>
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editOs">Operating System</Label>
<Select
value={editOs}
onValueChange={setEditOs}
>
<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>
)}
</div>
<div className="flex flex-col gap-2">
<Button
variant="destructive"
size="icon"
className="h-9 w-9"
onClick={() => deleteApplication(server.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
className="h-9 w-9"
onClick={() => openEditDialog(server)}
>
<Pencil className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Edit Server
</AlertDialogTitle>
<AlertDialogDescription>
<Tabs
defaultValue="general"
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="general">
General
</TabsTrigger>
<TabsTrigger value="hardware">
Hardware
</TabsTrigger>
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editOs">
Operating System
</Label>
<Select
value={editOs}
onValueChange={setEditOs}
>
<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">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu">CPU</Label>
<Input
id="editCpu"
value={editCpu}
onChange={(e) => setEditCpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editGpu">GPU</Label>
<Input
id="editGpu"
value={editGpu}
onChange={(e) => setEditGpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editRam">RAM</Label>
<Input
id="editRam"
value={editRam}
onChange={(e) => setEditRam(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editDisk">Disk</Label>
<Input
id="editDisk"
value={editDisk}
onChange={(e) => setEditDisk(e.target.value)}
/>
</div>
</div>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TabsContent value="hardware">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu">CPU</Label>
<Input
id="editCpu"
value={editCpu}
onChange={(e) =>
setEditCpu(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editGpu">GPU</Label>
<Input
id="editGpu"
value={editGpu}
onChange={(e) =>
setEditGpu(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editRam">RAM</Label>
<Input
id="editRam"
value={editRam}
onChange={(e) =>
setEditRam(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editDisk">
Disk
</Label>
<Input
id="editDisk"
value={editDisk}
onChange={(e) =>
setEditDisk(e.target.value)
}
/>
</div>
</div>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</div>
@@ -529,43 +727,54 @@ export default function Dashboard() {
</Card>
))}
</div>
:
) : (
<div className="flex items-center justify-center">
<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>
<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>
<clipPath id="clip0_9023_61563">
<rect width="24" height="24" fill="white"></rect>
</clipPath>
</defs>
</svg>
<span className='sr-only'>Loading...</span>
</div>
</svg>
<span className="sr-only">Loading...</span>
</div>
}
<div className="pt-4">
</div>
)}
<div className="pt-4 pb-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
<PaginationPrevious
onClick={handlePrevious}
isActive={currentPage > 1}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink isActive>{currentPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
<PaginationNext
onClick={handleNext}
isActive={currentPage < maxPage}
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
</PaginationContent>
@@ -574,5 +783,5 @@ export default function Dashboard() {
</div>
</SidebarInset>
</SidebarProvider>
)
}
);
}

View File

@@ -12,7 +12,7 @@ import {
SidebarProvider,
SidebarTrigger,
} 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 {
Select,
@@ -21,15 +21,121 @@ import {
SelectTrigger,
SelectValue,
} 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() {
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 (
<SidebarProvider>
<AppSidebar />
<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">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -50,32 +156,142 @@ export default function Settings() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<span className="text-2xl font-semibold">Settings</span>
<div className="pt-4">
<Card className="w-full mb-4 relative">
<CardHeader>
<span className="text-xl font-bold">Theme</span>
<Select
value={theme}
onValueChange={(value: string) => setTheme(value)}
>
<SelectTrigger className="w-full [&_svg]:hidden">
<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 className="p-6">
<div className="pb-4">
<span className="text-3xl font-bold">Settings</span>
</div>
<div className="grid gap-6">
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">User Settings</h2>
</div>
</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>
</div>
</div>
</SidebarInset>
</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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { AlertCircle } from "lucide-react"
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";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
export default function Home() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const [error, setError] = useState('');
const [errorVisible, setErrorVisible] = useState(false);
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [rememberMe, setRememberMe] = useState(false)
const router = useRouter()
const [error, setError] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const token = Cookies.get('token');
const token = Cookies.get("token")
if (token) {
router.push('/dashboard');
router.push("/dashboard")
}
}, [router]);
}, [router])
interface LoginResponse {
token: string;
token: string
}
const login = async () => {
try {
const response = await axios.post('/api/auth/login', { username, password });
const { token } = response.data as LoginResponse;
Cookies.set('token', token);
router.push('/dashboard');
} catch (error: any) {
setError(error.response.data.error);
if (!username || !password) {
setError("Please enter both email and password")
setErrorVisible(true)
return
}
try {
setIsLoading(true)
const response = await axios.post("/api/auth/login", { username, password })
const { token } = response.data as LoginResponse
const cookieOptions = rememberMe ? { expires: 7 } : {}
Cookies.set("token", token, cookieOptions)
router.push("/dashboard")
} catch (error: any) {
setError(error.response?.data?.error || "Login failed. Please try again.")
setErrorVisible(true)
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
login()
}
}
return (
<div className="flex flex-col min-h-screen items-center justify-center gap-6 ">
<Card className="w-1/3">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your Login data of the compose.yml file below to access
</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 className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-8 w-8 text-primary" />
</div>
</div>
</CardContent>
</Card>
<h1 className="text-4xl font-bold tracking-tight text-foreground">CoreControl</h1>
<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>
)
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
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 {
Sidebar,
SidebarContent,
@@ -18,8 +18,8 @@ import { Button } from "@/components/ui/button"
import Link from "next/link"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import packageJson from "@/package.json"
// Typdefinitionen
interface NavItem {
title: string
icon?: React.ComponentType<any>
@@ -50,6 +50,11 @@ const data: { navMain: NavItem[] } = {
icon: AppWindow,
url: "/dashboard/applications",
},
{
title: "Uptime",
icon: Activity,
url: "/dashboard/uptime",
},
{
title: "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"/>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">CoreControl</span>
<span className="">v0.0.1</span>
<span className="">v{packageJson.version}</span>
</div>
</a>
</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:
- "3000:3000"
environment:
LOGIN_EMAIL: "mail@example.com"
LOGIN_PASSWORD: "SecretPassword"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
@@ -16,7 +13,7 @@ services:
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db:
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!_

812
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "corecontrol",
"version": "0.1.0",
"version": "0.0.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -11,6 +11,7 @@
"dependencies": {
"@prisma/client": "^6.6.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-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
@@ -21,18 +22,20 @@
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",
"@types/axios": "^0.9.36",
"@types/bcrypt": "^5.0.2",
"@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@xyflow/react": "^12.5.5",
"axios": "^1.8.4",
"bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.487.0",
"next": "15.3.0",
"next-themes": "^0.4.6",
"pg": "^8.14.1",
"react": "^19.0.0",
"react-dom": "^19.0.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)
}
model uptime_history {
id Int @id @default(autoincrement())
applicationId Int @default(1)
online Boolean @default(true)
createdAt DateTime @default(now())
}
model server {
id Int @id @default(autoincrement())
name String
@@ -38,6 +45,12 @@ model server {
}
model settings {
id Int @id @default(autoincrement())
uptime_checks Boolean @default(true)
id Int @id @default(autoincrement())
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