mirror of
https://github.com/crocofied/CoreControl.git
synced 2025-12-29 16:14:43 +00:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676354f53c | ||
|
|
288bb4cc9e | ||
|
|
7b86de86e3 | ||
|
|
3e43c01e5a | ||
|
|
43c09e7a09 | ||
|
|
bdce02ac2b | ||
|
|
eee74c9df9 | ||
|
|
f21dae5646 | ||
|
|
c8a21797c9 | ||
|
|
32d99a09a6 | ||
|
|
7660f8bb5c | ||
|
|
8ff112e86e | ||
|
|
7eafdef288 | ||
|
|
724a634985 | ||
|
|
1e8682646d | ||
|
|
39ba85dcf4 | ||
|
|
a055d72246 | ||
|
|
aef0d6f812 | ||
|
|
b6e17e5d39 | ||
|
|
879ce1e6f1 | ||
|
|
c02b057f8e | ||
|
|
b0d7271d34 | ||
|
|
6ca1b2ac97 | ||
|
|
8059b0e541 | ||
|
|
9c9b556124 | ||
|
|
871ab74576 | ||
|
|
a3b47bd314 | ||
|
|
1088806921 | ||
|
|
a320c04b92 | ||
|
|
e34407539a | ||
|
|
f340543625 | ||
|
|
4aaefe4c55 | ||
|
|
12abe9c0d7 | ||
|
|
2f6957a45d | ||
|
|
ed46598c27 | ||
|
|
1a80c61c34 | ||
|
|
52f3c0432f | ||
|
|
faad5198a6 | ||
|
|
3f1f7b730e | ||
|
|
19ef051e1e | ||
|
|
75d1bd59f4 | ||
|
|
7da6501ca7 | ||
|
|
d779355c4c | ||
|
|
e844712c29 | ||
|
|
36beeb8a2c | ||
|
|
e51600016b | ||
|
|
8fc4fea687 | ||
|
|
0a8ea98dae | ||
|
|
7c86483d48 | ||
|
|
ca31a0b6b3 | ||
|
|
6661b1e711 | ||
|
|
5413dbf948 | ||
|
|
49fec67996 | ||
|
|
a1d9839bcc | ||
|
|
246f6b594c | ||
|
|
7549d8c8c0 | ||
|
|
fdb8b5073f | ||
|
|
07e7a60163 | ||
|
|
460c9103f7 | ||
|
|
14574e370c | ||
|
|
b915e0066d | ||
|
|
489c73ef77 | ||
|
|
0a41d27701 | ||
|
|
ebefb45fe6 | ||
|
|
5d352c2d1f | ||
|
|
75ca05454d | ||
|
|
e631d39b75 | ||
|
|
14f7c7fb22 | ||
|
|
8589ccb35f | ||
|
|
1373c5b92e | ||
|
|
06b422bfe9 | ||
|
|
fe9fd02b9a | ||
|
|
a2d4202a24 | ||
|
|
3bdadab7c6 | ||
|
|
130e282cd6 | ||
|
|
7023723a16 | ||
|
|
e41fa3a694 | ||
|
|
b4e2d9ee9c |
13
Dockerfile
13
Dockerfile
@@ -4,32 +4,37 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
COPY ./prisma ./prisma
|
COPY ./prisma ./prisma
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production Stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
# Install production dependencies INCLUDING prisma
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --production
|
RUN npm install --production --ignore-scripts
|
||||||
|
|
||||||
|
# Copy needed Prisma files
|
||||||
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma
|
||||||
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
|
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next ./.next
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
COPY --from=builder /app/next.config.js* ./
|
COPY --from=builder /app/next.config.js* ./
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "start"]
|
|
||||||
|
# Run migrations first, then start app
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]
|
||||||
47
README.md
47
README.md
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
|
The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
|
||||||
|
|
||||||
|
<a href="https://buymeacoffee.com/corecontrol" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dashboard: A clear screen with all the important information about your servers (WIP)
|
- Dashboard: A clear screen with all the important information about your servers (WIP)
|
||||||
@@ -13,11 +15,32 @@ The only dashboard you'll ever need to manage your entire server infrastructure.
|
|||||||
- Applications: Add all your self-hosted services to a clear list and track their up and down time
|
- Applications: Add all your self-hosted services to a clear list and track their up and down time
|
||||||
- Networks: Generate visually stunning network flowcharts with ease.
|
- Networks: Generate visually stunning network flowcharts with ease.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
Login Page:
|
||||||
|

|
||||||
|
|
||||||
|
Dashboard Page:
|
||||||
|

|
||||||
|
|
||||||
|
Servers Page:
|
||||||
|

|
||||||
|
|
||||||
|
Applications Page:
|
||||||
|

|
||||||
|
|
||||||
|
Uptime Page:
|
||||||
|

|
||||||
|
|
||||||
|
Network Page:
|
||||||
|

|
||||||
|
|
||||||
|
Settings Page:
|
||||||
|

|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
- [ ] Edit Applications, Applications searchbar
|
- [X] Edit Applications, Applications searchbar
|
||||||
- [ ] Customizable Dashboard
|
- [X] Uptime History
|
||||||
- [ ] Notifications
|
- [ ] Notifications
|
||||||
- [ ] Uptime History
|
|
||||||
- [ ] Simple Server Monitoring
|
- [ ] Simple Server Monitoring
|
||||||
- [ ] Improved Network Flowchart with custom elements (like Network switches)
|
- [ ] Improved Network Flowchart with custom elements (like Network switches)
|
||||||
- [ ] Advanced Settings (Disable Uptime Tracking & more)
|
- [ ] Advanced Settings (Disable Uptime Tracking & more)
|
||||||
@@ -32,11 +55,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
LOGIN_EMAIL: "mail@example.com"
|
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
|
||||||
LOGIN_PASSWORD: "SecretPassword"
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
JWT_SECRET: RANDOM_SECRET
|
|
||||||
ACCOUNT_SECRET: RANDOM_SECRET
|
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- agent
|
- agent
|
||||||
@@ -44,7 +64,7 @@ services:
|
|||||||
agent:
|
agent:
|
||||||
image: haedlessdev/corecontrol-agent:latest
|
image: haedlessdev/corecontrol-agent:latest
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -60,6 +80,10 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Default Login
|
||||||
|
__E-Mail:__ admin@example.com\
|
||||||
|
__Password:__ admin
|
||||||
|
|
||||||
## Tech Stack & Credits
|
## Tech Stack & Credits
|
||||||
|
|
||||||
The application is build with:
|
The application is build with:
|
||||||
@@ -69,8 +93,13 @@ The application is build with:
|
|||||||
- PostgreSQL with [Prisma ORM](https://www.prisma.io/)
|
- PostgreSQL with [Prisma ORM](https://www.prisma.io/)
|
||||||
- Icons by [Lucide](https://lucide.dev/)
|
- Icons by [Lucide](https://lucide.dev/)
|
||||||
- Flowcharts by [React Flow](https://reactflow.dev/)
|
- Flowcharts by [React Flow](https://reactflow.dev/)
|
||||||
|
- Application icons by [selfh.st/icons](selfh.st/icons)
|
||||||
- and a lot of love ❤️
|
- and a lot of love ❤️
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#crocofied/CoreControl&Date)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the [MIT License](https://github.com/crocofied/CoreControl/blob/main/LICENSE).
|
Licensed under the [MIT License](https://github.com/crocofied/CoreControl/blob/main/LICENSE).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
.env.local
|
.env
|
||||||
@@ -34,19 +34,48 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
go func() {
|
||||||
|
deletionTicker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer deletionTicker.Stop()
|
||||||
|
|
||||||
|
for range deletionTicker.C {
|
||||||
|
if err := deleteOldEntries(db); err != nil {
|
||||||
|
fmt.Printf("Error deleting old entries: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 4 * time.Second,
|
Timeout: 4 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
for range ticker.C {
|
for now := range ticker.C {
|
||||||
|
if now.Second()%10 != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
apps := getApplications(db)
|
apps := getApplications(db)
|
||||||
checkAndUpdateStatus(db, client, apps)
|
checkAndUpdateStatus(db, client, apps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteOldEntries(db *sql.DB) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getApplications(db *sql.DB) []Application {
|
func getApplications(db *sql.DB) []Application {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, "publicURL", online
|
SELECT id, "publicURL", online
|
||||||
@@ -90,12 +119,21 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx,
|
_, err = db.ExecContext(ctx,
|
||||||
"UPDATE application SET online = $1 WHERE id = $2",
|
`UPDATE application SET online = $1 WHERE id = $2`,
|
||||||
isOnline,
|
isOnline,
|
||||||
app.ID,
|
app.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
|
fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx,
|
||||||
|
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
|
||||||
|
app.ID,
|
||||||
|
isOnline,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Insert into uptime_history failed for app %d: %v\n", app.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { id: id }
|
where: { id: id }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.uptime_history.deleteMany({
|
||||||
|
where: { applicationId: id }
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
|||||||
40
app/api/applications/edit/route.ts
Normal file
40
app/api/applications/edit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/applications/search/route.ts
Normal file
32
app/api/applications/search/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/api/applications/uptime/route.ts
Normal file
162
app/api/applications/uptime/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/auth/edit_email/route.ts
Normal file
56
app/api/auth/edit_email/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
interface EditEmailRequest {
|
||||||
|
newEmail: string;
|
||||||
|
jwtToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body: EditEmailRequest = await request.json();
|
||||||
|
const { newEmail, jwtToken } = body;
|
||||||
|
|
||||||
|
// Ensure JWT_SECRET is defined
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
throw new Error('JWT_SECRET is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT
|
||||||
|
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
|
||||||
|
if (!decoded.account_secret) {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user by account id
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.account_secret },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Check if the new email is already in use
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: newEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json({ error: 'Email already in use' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user's email
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { email: newEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Email updated successfully' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/api/auth/edit_password/route.ts
Normal file
55
app/api/auth/edit_password/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from '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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse, NextRequest } from "next/server";
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
interface LoginRequest {
|
interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -11,17 +13,50 @@ export async function POST(request: NextRequest) {
|
|||||||
const body: LoginRequest = await request.json();
|
const body: LoginRequest = await request.json();
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
|
|
||||||
if(username !== process.env.LOGIN_EMAIL || password !== process.env.LOGIN_PASSWORD) {
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure JWT_SECRET is defined
|
// Ensure JWT_SECRET is defined
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw new Error('JWT_SECRET is not defined');
|
throw new Error('JWT_SECRET is not defined');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let accountId: string = '';
|
||||||
|
// Check if there are any entries in user
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount === 0) {
|
||||||
|
if(username=== "admin@example.com" && password === "admin") {
|
||||||
|
// Hash the password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
// Create the first user with hashed password
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: username,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the account id
|
||||||
|
accountId = user.id;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get the user by username
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: username },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Check if the password is correct
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Get the account id
|
||||||
|
accountId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Create JWT
|
// Create JWT
|
||||||
const token = jwt.sign({ account_secret: process.env.ACCOUNT_SECRET }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
const token = jwt.sign({ account_secret: accountId }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
|
||||||
return NextResponse.json({ token });
|
return NextResponse.json({ token });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
interface ValidateRequest {
|
interface ValidateRequest {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -16,6 +16,14 @@ export async function POST(request: NextRequest) {
|
|||||||
throw new Error('JWT_SECRET is not defined');
|
throw new Error('JWT_SECRET is not defined');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the account id
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Verify JWT
|
// Verify JWT
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
|
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
|
||||||
|
|
||||||
@@ -23,7 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if(decoded.account_secret !== process.env.ACCOUNT_SECRET) {
|
if(decoded.account_secret !== user.id) {
|
||||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ interface Node {
|
|||||||
};
|
};
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
|
draggable?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
zIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Edge {
|
interface Edge {
|
||||||
@@ -38,10 +41,13 @@ interface Application {
|
|||||||
|
|
||||||
const NODE_WIDTH = 220;
|
const NODE_WIDTH = 220;
|
||||||
const NODE_HEIGHT = 60;
|
const NODE_HEIGHT = 60;
|
||||||
|
const APP_NODE_WIDTH = 160;
|
||||||
|
const APP_NODE_HEIGHT = 40;
|
||||||
const HORIZONTAL_SPACING = 280;
|
const HORIZONTAL_SPACING = 280;
|
||||||
const VERTICAL_SPACING = 80;
|
const VERTICAL_SPACING = 60;
|
||||||
const START_Y = 120;
|
const START_Y = 120;
|
||||||
const ROOT_NODE_WIDTH = 300;
|
const ROOT_NODE_WIDTH = 300;
|
||||||
|
const CONTAINER_PADDING = 40;
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -54,11 +60,12 @@ export async function GET() {
|
|||||||
}) as Promise<Application[]>,
|
}) as Promise<Application[]>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Root Node
|
||||||
const rootNode: Node = {
|
const rootNode: Node = {
|
||||||
id: "root",
|
id: "root",
|
||||||
type: "infrastructure",
|
type: "infrastructure",
|
||||||
data: { label: "My Infrastructure" },
|
data: { label: "My Infrastructure" },
|
||||||
position: { x: 0, y: 20 },
|
position: { x: 0, y: 0 },
|
||||||
style: {
|
style: {
|
||||||
background: "#ffffff",
|
background: "#ffffff",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
@@ -72,6 +79,7 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Server Nodes
|
||||||
const serverNodes: Node[] = servers.map((server, index) => {
|
const serverNodes: Node[] = servers.map((server, index) => {
|
||||||
const xPos =
|
const xPos =
|
||||||
index * HORIZONTAL_SPACING -
|
index * HORIZONTAL_SPACING -
|
||||||
@@ -100,11 +108,12 @@ export async function GET() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Application Nodes
|
||||||
const appNodes: Node[] = [];
|
const appNodes: Node[] = [];
|
||||||
servers.forEach((server) => {
|
servers.forEach((server) => {
|
||||||
const serverX =
|
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
|
||||||
serverNodes.find((n) => n.id === `server-${server.id}`)?.position.x || 0;
|
const serverX = serverNode?.position.x || 0;
|
||||||
const serverY = START_Y;
|
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
|
||||||
|
|
||||||
applications
|
applications
|
||||||
.filter((app) => app.serverId === server.id)
|
.filter((app) => app.serverId === server.id)
|
||||||
@@ -117,25 +126,26 @@ export async function GET() {
|
|||||||
...app,
|
...app,
|
||||||
},
|
},
|
||||||
position: {
|
position: {
|
||||||
x: serverX,
|
x: serverX + xOffset,
|
||||||
y: serverY + NODE_HEIGHT + 40 + appIndex * VERTICAL_SPACING,
|
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
background: "#ffffff",
|
background: "#f5f5f5",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
border: "2px solid #e6e4e1",
|
border: "2px solid #e6e4e1",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
padding: "8px",
|
padding: "6px",
|
||||||
width: NODE_WIDTH,
|
width: APP_NODE_WIDTH,
|
||||||
height: NODE_HEIGHT,
|
height: APP_NODE_HEIGHT,
|
||||||
fontSize: "0.9rem",
|
fontSize: "0.8rem",
|
||||||
lineHeight: "1.2",
|
lineHeight: "1.1",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connections
|
||||||
const connections: Edge[] = [
|
const connections: Edge[] = [
|
||||||
...servers.map((server) => ({
|
...servers.map((server) => ({
|
||||||
id: `conn-root-${server.id}`,
|
id: `conn-root-${server.id}`,
|
||||||
@@ -159,8 +169,46 @@ export async function GET() {
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Container Box
|
||||||
|
const allNodes = [rootNode, ...serverNodes, ...appNodes];
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
allNodes.forEach((node) => {
|
||||||
|
const width = parseInt(node.style.width?.toString() || "0", 10);
|
||||||
|
const height = parseInt(node.style.height?.toString() || "0", 10);
|
||||||
|
|
||||||
|
minX = Math.min(minX, node.position.x);
|
||||||
|
maxX = Math.max(maxX, node.position.x + width);
|
||||||
|
minY = Math.min(minY, node.position.y);
|
||||||
|
maxY = Math.max(maxY, node.position.y + height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerNode: Node = {
|
||||||
|
id: 'container',
|
||||||
|
type: 'container',
|
||||||
|
data: { label: '' },
|
||||||
|
position: {
|
||||||
|
x: minX - CONTAINER_PADDING,
|
||||||
|
y: minY - CONTAINER_PADDING
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: maxX - minX + 2 * CONTAINER_PADDING,
|
||||||
|
height: maxY - minY + 2 * CONTAINER_PADDING,
|
||||||
|
background: 'transparent',
|
||||||
|
border: '2px dashed #e2e8f0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
zIndex: -1,
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
nodes: [rootNode, ...serverNodes, ...appNodes],
|
nodes: [containerNode, ...allNodes],
|
||||||
edges: connections,
|
edges: connections,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there are any applications associated with the server
|
||||||
|
const applications = await prisma.application.findMany({
|
||||||
|
where: { serverId: id }
|
||||||
|
});
|
||||||
|
if (applications.length > 0) {
|
||||||
|
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.server.delete({
|
await prisma.server.delete({
|
||||||
where: { id: id }
|
where: { id: id }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { NextResponse, NextRequest } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
interface GetRequest {
|
interface GetRequest {
|
||||||
page: number;
|
page?: number;
|
||||||
|
ITEMS_PER_PAGE?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body: GetRequest = await request.json();
|
const body: GetRequest = await request.json();
|
||||||
const page = Math.max(1, body.page || 1);
|
const page = Math.max(1, body.page || 1);
|
||||||
|
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
|
||||||
|
|
||||||
const servers = await prisma.server.findMany({
|
const servers = await prisma.server.findMany({
|
||||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||||
|
|||||||
32
app/api/servers/search/route.ts
Normal file
32
app/api/servers/search/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,61 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar";
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import axios from "axios"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Activity, Layers, Network, Server } from "lucide-react"
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb"
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator"
|
||||||
import {
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
SidebarInset,
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
SidebarProvider,
|
import { Button } from "@/components/ui/button"
|
||||||
SidebarTrigger,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import axios from "axios"; // Korrekter Import
|
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface StatsResponse {
|
interface StatsResponse {
|
||||||
serverCount: number;
|
serverCount: number
|
||||||
applicationCount: number;
|
applicationCount: number
|
||||||
onlineApplicationsCount: number;
|
onlineApplicationsCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [serverCount, setServerCount] = useState<number>(0);
|
const [serverCount, setServerCount] = useState<number>(0)
|
||||||
const [applicationCount, setApplicationCount] = useState<number>(0);
|
const [applicationCount, setApplicationCount] = useState<number>(0)
|
||||||
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0);
|
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
|
||||||
|
|
||||||
const getStats = async () => {
|
const getStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<StatsResponse>('/api/dashboard/get', {});
|
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
|
||||||
setServerCount(response.data.serverCount);
|
setServerCount(response.data.serverCount)
|
||||||
setApplicationCount(response.data.applicationCount);
|
setApplicationCount(response.data.applicationCount)
|
||||||
setOnlineApplicationsCount(response.data.onlineApplicationsCount);
|
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log("Axios error:", error.response?.data);
|
console.log("Axios error:", error.response?.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStats();
|
getStats()
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem className="hidden md:block">
|
<BreadcrumbItem className="hidden md:block">
|
||||||
<BreadcrumbPage>
|
<BreadcrumbPage>/</BreadcrumbPage>
|
||||||
/
|
|
||||||
</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
@@ -66,51 +65,105 @@ export default function Dashboard() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="pl-4 pr-4">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
|
||||||
<Card className="w-full mb-4 relative">
|
|
||||||
<CardHeader>
|
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
|
||||||
<div className="flex items-center justify-center w-full">
|
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<CardHeader className="pb-2">
|
||||||
<span className="text-2xl font-bold">{serverCount}</span>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-md">Servers</span>
|
<CardTitle className="text-xl font-medium">Servers</CardTitle>
|
||||||
</div>
|
<Server className="h-6 w-6 text-rose-500" />
|
||||||
</div>
|
</div>
|
||||||
|
<CardDescription>Manage your server infrastructure</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 pb-4">
|
||||||
|
<div className="text-4xl font-bold">{serverCount}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="border-t bg-muted/20 p-4">
|
||||||
|
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
|
||||||
|
<Link href="/dashboard/servers">View all servers</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="w-full mb-4 relative">
|
|
||||||
<CardHeader>
|
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md">
|
||||||
<div className="flex items-center justify-center w-full">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-2xl font-bold">{applicationCount}</span>
|
<CardTitle className="text-xl font-medium">Applications</CardTitle>
|
||||||
<span className="text-md">Applications</span>
|
<Layers className="h-6 w-6 text-amber-500" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<CardDescription>Manage your deployed applications</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 pb-4">
|
||||||
|
<div className="text-4xl font-bold">{applicationCount}</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Running applications</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="border-t bg-muted/20 p-4">
|
||||||
|
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
|
||||||
|
<Link href="/dashboard/applications">View all applications</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="w-full mb-4 relative">
|
|
||||||
<CardHeader>
|
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-sm transition-all hover:shadow-md">
|
||||||
<div className="flex items-center justify-center w-full">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-2xl font-bold">
|
<CardTitle className="text-xl font-medium">Uptime</CardTitle>
|
||||||
|
<Activity className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardDescription>Monitor your service availability</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 pb-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-4xl font-bold flex items-center justify-between">
|
||||||
|
<span>
|
||||||
{onlineApplicationsCount}/{applicationCount}
|
{onlineApplicationsCount}/{applicationCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-md">Applications are online</span>
|
<div className="flex items-center bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-lg font-semibold">
|
||||||
|
{applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
|
<CardDescription>Manage network configuration</CardDescription>
|
||||||
<span className="text-gray-400 text-2xl">COMING SOON</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="pt-2 pb-4">
|
||||||
<div className="pt-4">
|
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
|
||||||
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
|
<p className="text-sm text-muted-foreground mt-2">Active connections</p>
|
||||||
<span className="text-gray-400 text-2xl">COMING SOON</span>
|
</CardContent>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,16 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Link, Home, Trash2, LayoutGrid, List } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Link,
|
||||||
|
Home,
|
||||||
|
Trash2,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Pencil,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -58,6 +67,12 @@ import {
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
interface Application {
|
interface Application {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -89,6 +104,15 @@ export default function Dashboard() {
|
|||||||
const [publicURL, setPublicURL] = useState<string>("");
|
const [publicURL, setPublicURL] = useState<string>("");
|
||||||
const [localURL, setLocalURL] = useState<string>("");
|
const [localURL, setLocalURL] = useState<string>("");
|
||||||
const [serverId, setServerId] = useState<number | null>(null);
|
const [serverId, setServerId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [editName, setEditName] = useState<string>("");
|
||||||
|
const [editDescription, setEditDescription] = useState<string>("");
|
||||||
|
const [editIcon, setEditIcon] = useState<string>("");
|
||||||
|
const [editPublicURL, setEditPublicURL] = useState<string>("");
|
||||||
|
const [editLocalURL, setEditLocalURL] = useState<string>("");
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [editServerId, setEditServerId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [maxPage, setMaxPage] = useState<number>(1);
|
const [maxPage, setMaxPage] = useState<number>(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
||||||
@@ -97,9 +121,12 @@ export default function Dashboard() {
|
|||||||
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
|
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedLayout = Cookies.get('layoutPreference-app');
|
const savedLayout = Cookies.get("layoutPreference-app");
|
||||||
const layout_bool = savedLayout === 'grid';
|
const layout_bool = savedLayout === "grid";
|
||||||
setIsGridLayout(layout_bool);
|
setIsGridLayout(layout_bool);
|
||||||
setItemsPerPage(layout_bool ? 15 : 5);
|
setItemsPerPage(layout_bool ? 15 : 5);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -107,35 +134,35 @@ export default function Dashboard() {
|
|||||||
const toggleLayout = () => {
|
const toggleLayout = () => {
|
||||||
const newLayout = !isGridLayout;
|
const newLayout = !isGridLayout;
|
||||||
setIsGridLayout(newLayout);
|
setIsGridLayout(newLayout);
|
||||||
Cookies.set('layoutPreference-app', newLayout ? 'grid' : 'standard', {
|
Cookies.set("layoutPreference-app", newLayout ? "grid" : "standard", {
|
||||||
expires: 365,
|
expires: 365,
|
||||||
path: '/',
|
path: "/",
|
||||||
sameSite: 'strict'
|
sameSite: "strict",
|
||||||
});
|
});
|
||||||
setItemsPerPage(newLayout ? 15 : 5);
|
setItemsPerPage(newLayout ? 15 : 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/applications/add', {
|
await axios.post("/api/applications/add", {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
publicURL,
|
publicURL,
|
||||||
localURL,
|
localURL,
|
||||||
serverId
|
serverId,
|
||||||
});
|
});
|
||||||
getApplications();
|
getApplications();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response?.data);
|
console.log(error.response?.data);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const getApplications = async () => {
|
const getApplications = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.post<ApplicationsResponse>(
|
const response = await axios.post<ApplicationsResponse>(
|
||||||
'/api/applications/get',
|
"/api/applications/get",
|
||||||
{ page: currentPage, ITEMS_PER_PAGE: itemsPerPage }
|
{ page: currentPage, ITEMS_PER_PAGE: itemsPerPage }
|
||||||
);
|
);
|
||||||
setApplications(response.data.applications);
|
setApplications(response.data.applications);
|
||||||
@@ -145,29 +172,95 @@ export default function Dashboard() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response?.data);
|
console.log(error.response?.data);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getApplications();
|
getApplications();
|
||||||
}, [currentPage, itemsPerPage]);
|
}, [currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const handlePrevious = () => setCurrentPage(prev => Math.max(1, prev - 1));
|
const handlePrevious = () => setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||||
const handleNext = () => setCurrentPage(prev => Math.min(maxPage, prev + 1));
|
const handleNext = () =>
|
||||||
|
setCurrentPage((prev) => Math.min(maxPage, prev + 1));
|
||||||
|
|
||||||
const deleteApplication = async (id: number) => {
|
const deleteApplication = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/applications/delete', { id });
|
await axios.post("/api/applications/delete", { id });
|
||||||
getApplications();
|
getApplications();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response?.data);
|
console.log(error.response?.data);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (app: Application) => {
|
||||||
|
setEditId(app.id);
|
||||||
|
setEditServerId(app.serverId);
|
||||||
|
setEditName(app.name);
|
||||||
|
setEditDescription(app.description || "");
|
||||||
|
setEditIcon(app.icon || "");
|
||||||
|
setEditLocalURL(app.localURL || "");
|
||||||
|
setEditPublicURL(app.publicURL || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = async () => {
|
||||||
|
if (!editId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.put("/api/applications/edit", {
|
||||||
|
id: editId,
|
||||||
|
serverId: editServerId,
|
||||||
|
name: editName,
|
||||||
|
description: editDescription,
|
||||||
|
icon: editIcon,
|
||||||
|
publicURL: editPublicURL,
|
||||||
|
localURL: editLocalURL,
|
||||||
|
});
|
||||||
|
getApplications();
|
||||||
|
setEditId(null);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(error.response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchApplications = async () => {
|
||||||
|
try {
|
||||||
|
setIsSearching(true);
|
||||||
|
const response = await axios.post<{ results: Application[] }>(
|
||||||
|
"/api/applications/search",
|
||||||
|
{ searchterm: searchTerm }
|
||||||
|
);
|
||||||
|
setApplications(response.data.results);
|
||||||
|
setIsSearching(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Search error:", error.response?.data);
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delayDebounce = setTimeout(() => {
|
||||||
|
if (searchTerm.trim() === "") {
|
||||||
|
getApplications();
|
||||||
|
} else {
|
||||||
|
searchApplications();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounce);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const generateIconURL = async () => {
|
||||||
|
setIcon("https://cdn.jsdelivr.net/gh/selfhst/icons/png/" + name.toLowerCase() + ".png")
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateEditIconURL = async () => {
|
||||||
|
setEditIcon("https://cdn.jsdelivr.net/gh/selfhst/icons/png/" + editName.toLowerCase() + ".png")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
@@ -188,20 +281,28 @@ export default function Dashboard() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="pl-4 pr-4">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-2xl font-semibold">Your Applications</span>
|
<span className="text-3xl font-bold">Your Applications</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleLayout}
|
onClick={toggleLayout}
|
||||||
title={isGridLayout ? "Switch to list view" : "Switch to grid view"}
|
title={
|
||||||
|
isGridLayout ? "Switch to list view" : "Switch to grid view"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isGridLayout ? <List className="h-4 w-4" /> : <LayoutGrid className="h-4 w-4" />}
|
{isGridLayout ? (
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
<p className="text-muted-foreground">You must first add a server.</p>
|
<p className="text-muted-foreground">
|
||||||
|
You must first add a server.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
@@ -216,17 +317,26 @@ export default function Dashboard() {
|
|||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input placeholder="e.g. Portainer" onChange={(e) => setName(e.target.value)}/>
|
<Input
|
||||||
|
placeholder="e.g. Portainer"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Server</Label>
|
<Label>Server</Label>
|
||||||
<Select onValueChange={(v) => setServerId(Number(v))} required>
|
<Select
|
||||||
|
onValueChange={(v) => setServerId(Number(v))}
|
||||||
|
required
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select server" />
|
<SelectValue placeholder="Select server" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<SelectItem key={server.id} value={String(server.id)}>
|
<SelectItem
|
||||||
|
key={server.id}
|
||||||
|
value={String(server.id)}
|
||||||
|
>
|
||||||
{server.name}
|
{server.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -234,27 +344,66 @@ export default function Dashboard() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Description <span className="text-stone-600">(optional)</span></Label>
|
<Label>
|
||||||
<Textarea placeholder="Application description" onChange={(e) => setDescription(e.target.value)}/>
|
Description{" "}
|
||||||
|
<span className="text-stone-600">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Application description"
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Icon URL <span className="text-stone-600">(optional)</span></Label>
|
<Label>
|
||||||
<Input placeholder="https://example.com/icon.png" onChange={(e) => setIcon(e.target.value)}/>
|
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>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Public URL</Label>
|
<Label>Public URL</Label>
|
||||||
<Input placeholder="https://example.com" onChange={(e) => setPublicURL(e.target.value)}/>
|
<Input
|
||||||
|
placeholder="https://example.com"
|
||||||
|
onChange={(e) => setPublicURL(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label>Local URL <span className="text-stone-600">(optional)</span></Label>
|
<Label>
|
||||||
<Input placeholder="http://localhost:3000" onChange={(e) => setLocalURL(e.target.value)}/>
|
Local URL{" "}
|
||||||
|
<span className="text-stone-600">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="http://localhost:3000"
|
||||||
|
onChange={(e) => setLocalURL(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={add} disabled={!name || !publicURL || !serverId}>
|
<AlertDialogAction
|
||||||
|
onClick={add}
|
||||||
|
disabled={!name || !publicURL || !serverId}
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -263,39 +412,70 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-4 pt-2">
|
||||||
|
<Input
|
||||||
|
id="application-search"
|
||||||
|
placeholder="Type to search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
{!loading ?
|
{!loading ? (
|
||||||
<div className={isGridLayout ?
|
<div
|
||||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" :
|
className={
|
||||||
"space-y-4"}>
|
isGridLayout
|
||||||
|
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
: "space-y-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
{applications.map((app) => (
|
{applications.map((app) => (
|
||||||
<Card
|
<Card
|
||||||
key={app.id}
|
key={app.id}
|
||||||
className={isGridLayout ? "h-full flex flex-col justify-between relative" : "w-full mb-4 relative"}
|
className={
|
||||||
|
isGridLayout
|
||||||
|
? "h-full flex flex-col justify-between relative"
|
||||||
|
: "w-full mb-4 relative"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full flex items-center justify-center ${app.online ? "bg-green-700" : "bg-red-700"}`}
|
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"}
|
title={app.online ? "Online" : "Offline"}
|
||||||
>
|
>
|
||||||
<div className={`w-2 h-2 rounded-full ${app.online ? "bg-green-500" : "bg-red-500"}`} />
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
app.online ? "bg-green-500" : "bg-red-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
|
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
|
||||||
{app.icon ? (
|
{app.icon ? (
|
||||||
<img src={app.icon} alt={app.name} className="w-full h-full object-contain rounded-md" />
|
<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>
|
<span className="text-gray-500 text-xs">Image</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<CardTitle className="text-2xl font-bold">{app.name}</CardTitle>
|
<CardTitle className="text-2xl font-bold">
|
||||||
|
{app.name}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-md">
|
<CardDescription className="text-md">
|
||||||
{app.description}<br />
|
{app.description}
|
||||||
Server: {app.server || 'No server'}
|
{app.description && (
|
||||||
|
<br className="hidden md:block" />
|
||||||
|
)}
|
||||||
|
Server: {app.server || "No server"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +485,9 @@ export default function Dashboard() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 w-full"
|
className="gap-2 w-full"
|
||||||
onClick={() => window.open(app.publicURL, "_blank")}
|
onClick={() =>
|
||||||
|
window.open(app.publicURL, "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Link className="h-4 w-4" />
|
<Link className="h-4 w-4" />
|
||||||
Open Public URL
|
Open Public URL
|
||||||
@@ -314,21 +496,156 @@ export default function Dashboard() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 w-full"
|
className="gap-2 w-full"
|
||||||
onClick={() => window.open(app.localURL, "_blank")}
|
onClick={() =>
|
||||||
|
window.open(app.localURL, "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
Open Local URL
|
Open Local URL
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-[72px] w-10"
|
className="h-9 w-9"
|
||||||
onClick={() => deleteApplication(app.id)}
|
onClick={() => deleteApplication(app.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,31 +653,42 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
:
|
) : (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className='inline-block' role='status' aria-label='loading'>
|
<div className="inline-block" role="status" aria-label="loading">
|
||||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
<svg
|
||||||
<g clip-path='url(#clip0_9023_61563)'>
|
className="w-6 h-6 stroke-white animate-spin "
|
||||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_9023_61563)">
|
||||||
|
<path
|
||||||
|
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
|
||||||
|
stroke="stroke-current"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
className="my-path"
|
||||||
|
></path>
|
||||||
</g>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id='clip0_9023_61563'>
|
<clipPath id="clip0_9023_61563">
|
||||||
<rect width='24' height='24' fill='white'></rect>
|
<rect width="24" height="24" fill="white"></rect>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='sr-only'>Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
<div className="pt-4">
|
<div className="pt-4 pb-4">
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
href="#"
|
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
isActive={currentPage > 1}
|
isActive={currentPage > 1}
|
||||||
|
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
@@ -368,9 +696,9 @@ export default function Dashboard() {
|
|||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
href="#"
|
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
isActive={currentPage < maxPage}
|
isActive={currentPage < maxPage}
|
||||||
|
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
@@ -379,5 +707,5 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ export default function Dashboard() {
|
|||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="flex flex-col h-screen">
|
<SidebarInset className="flex flex-col h-screen">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1 dark:text-white" />
|
<SidebarTrigger className="-ml-1 dark:text-white" />
|
||||||
<Separator
|
<Separator
|
||||||
|
|||||||
@@ -16,7 +16,20 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Link, MonitorCog, FileDigit, Trash2, LayoutGrid, List, Pencil, Cpu, Microchip, MemoryStick, HardDrive } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Link,
|
||||||
|
MonitorCog,
|
||||||
|
FileDigit,
|
||||||
|
Trash2,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Pencil,
|
||||||
|
Cpu,
|
||||||
|
Microchip,
|
||||||
|
MemoryStick,
|
||||||
|
HardDrive,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -62,7 +75,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
@@ -94,6 +107,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [maxPage, setMaxPage] = useState<number>(1);
|
const [maxPage, setMaxPage] = useState<number>(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState<number>(4);
|
||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
|
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
@@ -108,24 +122,30 @@ export default function Dashboard() {
|
|||||||
const [editRam, setEditRam] = useState<string>("");
|
const [editRam, setEditRam] = useState<string>("");
|
||||||
const [editDisk, setEditDisk] = useState<string>("");
|
const [editDisk, setEditDisk] = useState<string>("");
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedLayout = Cookies.get('layoutPreference-servers');
|
const savedLayout = Cookies.get("layoutPreference-servers");
|
||||||
setIsGridLayout(savedLayout === 'grid');
|
const layout_bool = savedLayout === "grid";
|
||||||
|
setIsGridLayout(layout_bool);
|
||||||
|
setItemsPerPage(layout_bool ? 6 : 4);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleLayout = () => {
|
const toggleLayout = () => {
|
||||||
const newLayout = !isGridLayout;
|
const newLayout = !isGridLayout;
|
||||||
setIsGridLayout(newLayout);
|
setIsGridLayout(newLayout);
|
||||||
Cookies.set('layoutPreference-servers', newLayout ? 'grid' : 'standard', {
|
Cookies.set("layoutPreference-servers", newLayout ? "grid" : "standard", {
|
||||||
expires: 365,
|
expires: 365,
|
||||||
path: '/',
|
path: "/",
|
||||||
sameSite: 'strict'
|
sameSite: "strict",
|
||||||
});
|
});
|
||||||
|
setItemsPerPage(newLayout ? 6 : 4);
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/servers/add', {
|
await axios.post("/api/servers/add", {
|
||||||
name,
|
name,
|
||||||
os,
|
os,
|
||||||
ip,
|
ip,
|
||||||
@@ -133,48 +153,52 @@ export default function Dashboard() {
|
|||||||
cpu,
|
cpu,
|
||||||
gpu,
|
gpu,
|
||||||
ram,
|
ram,
|
||||||
disk
|
disk,
|
||||||
});
|
});
|
||||||
getServers();
|
getServers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const getServers = async () => {
|
const getServers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.post<GetServersResponse>('/api/servers/get', {
|
const response = await axios.post<GetServersResponse>(
|
||||||
page: currentPage
|
"/api/servers/get",
|
||||||
});
|
{
|
||||||
|
page: currentPage,
|
||||||
|
ITEMS_PER_PAGE: itemsPerPage,
|
||||||
|
}
|
||||||
|
);
|
||||||
setServers(response.data.servers);
|
setServers(response.data.servers);
|
||||||
setMaxPage(response.data.maxPage);
|
setMaxPage(response.data.maxPage);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response);
|
console.log(error.response);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getServers();
|
getServers();
|
||||||
}, [currentPage]);
|
}, [currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const handlePrevious = () => {
|
const handlePrevious = () => {
|
||||||
setCurrentPage(prev => Math.max(1, prev - 1));
|
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
setCurrentPage(prev => Math.min(maxPage, prev + 1));
|
setCurrentPage((prev) => Math.min(maxPage, prev + 1));
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteApplication = async (id: number) => {
|
const deleteApplication = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/servers/delete', { id });
|
await axios.post("/api/servers/delete", { id });
|
||||||
getServers();
|
getServers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const openEditDialog = (server: Server) => {
|
const openEditDialog = (server: Server) => {
|
||||||
setEditId(server.id);
|
setEditId(server.id);
|
||||||
@@ -192,7 +216,7 @@ export default function Dashboard() {
|
|||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.put('/api/servers/edit', {
|
await axios.put("/api/servers/edit", {
|
||||||
id: editId,
|
id: editId,
|
||||||
name: editName,
|
name: editName,
|
||||||
os: editOs,
|
os: editOs,
|
||||||
@@ -201,20 +225,47 @@ export default function Dashboard() {
|
|||||||
cpu: editCpu,
|
cpu: editCpu,
|
||||||
gpu: editGpu,
|
gpu: editGpu,
|
||||||
ram: editRam,
|
ram: editRam,
|
||||||
disk: editDisk
|
disk: editDisk,
|
||||||
});
|
});
|
||||||
getServers();
|
getServers();
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error.response.data);
|
console.log(error.response.data);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchServers = async () => {
|
||||||
|
try {
|
||||||
|
setIsSearching(true);
|
||||||
|
const response = await axios.post<{ results: Server[] }>(
|
||||||
|
"/api/servers/search",
|
||||||
|
{ searchterm: searchTerm }
|
||||||
|
);
|
||||||
|
setServers(response.data.results);
|
||||||
|
setIsSearching(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Search error:", error.response?.data);
|
||||||
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delayDebounce = setTimeout(() => {
|
||||||
|
if (searchTerm.trim() === "") {
|
||||||
|
getServers();
|
||||||
|
} else {
|
||||||
|
searchServers();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounce);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
@@ -235,9 +286,9 @@ export default function Dashboard() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="pl-4 pr-4">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-2xl font-semibold">Your Servers</span>
|
<span className="text-3xl font-bold">Your Servers</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -255,9 +306,9 @@ export default function Dashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{isGridLayout ?
|
{isGridLayout
|
||||||
"Switch to list view" :
|
? "Switch to list view"
|
||||||
"Switch to grid view"}
|
: "Switch to grid view"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -280,57 +331,131 @@ export default function Dashboard() {
|
|||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
<Input id="name" type="text" placeholder="e.g. Server1" onChange={(e) => setName(e.target.value)}/>
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Server1"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="description">Operating System <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="description">
|
||||||
|
Operating System{" "}
|
||||||
|
<span className="text-stone-600">
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
<Select onValueChange={(value) => setOs(value)}>
|
<Select onValueChange={(value) => setOs(value)}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select OS" />
|
<SelectValue placeholder="Select OS" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Windows">Windows</SelectItem>
|
<SelectItem value="Windows">
|
||||||
|
Windows
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="Linux">Linux</SelectItem>
|
<SelectItem value="Linux">Linux</SelectItem>
|
||||||
<SelectItem value="MacOS">MacOS</SelectItem>
|
<SelectItem value="MacOS">MacOS</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="icon">IP Adress <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="icon">
|
||||||
<Input id="icon" type="text" placeholder="e.g. 192.168.100.2" onChange={(e) => setIp(e.target.value)}/>
|
IP Adress{" "}
|
||||||
|
<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>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Label htmlFor="publicURL">Management URL <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="publicURL">
|
||||||
|
Management URL{" "}
|
||||||
|
<span className="text-stone-600">
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
Link to a web interface (e.g. Proxmox or Portainer) with which the server can be managed
|
Link to a web interface (e.g. Proxmox or
|
||||||
|
Portainer) with which the server can be
|
||||||
|
managed
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<Input id="publicURL" type="text" placeholder="e.g. https://proxmox.server1.com" onChange={(e) => setUrl(e.target.value)}/>
|
<Input
|
||||||
|
id="publicURL"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. https://proxmox.server1.com"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="hardware">
|
<TabsContent value="hardware">
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="name">CPU <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="name">
|
||||||
<Input id="name" type="text" placeholder="e.g. AMD Ryzen™ 7 7800X3D" onChange={(e) => setCpu(e.target.value)}/>
|
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>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="name">GPU <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="name">
|
||||||
<Input id="name" type="text" placeholder="e.g. AMD Radeon™ Graphics" onChange={(e) => setGpu(e.target.value)}/>
|
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>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="name">RAM <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="name">
|
||||||
<Input id="name" type="text" placeholder="e.g. 64GB DDR5" onChange={(e) => setRam(e.target.value)}/>
|
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>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="name">Disk <span className="text-stone-600">(optional)</span></Label>
|
<Label htmlFor="name">
|
||||||
<Input id="name" type="text" placeholder="e.g. 2TB SSD" onChange={(e) => setDisk(e.target.value)}/>
|
Disk{" "}
|
||||||
|
<span className="text-stone-600">
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 2TB SSD"
|
||||||
|
onChange={(e) => setDisk(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -345,31 +470,57 @@ export default function Dashboard() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 mb-4 pt-2">
|
||||||
|
<Input
|
||||||
|
id="application-search"
|
||||||
|
placeholder="Type to search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
{!loading ?
|
{!loading ? (
|
||||||
<div className={isGridLayout ?
|
<div
|
||||||
"grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4" :
|
className={
|
||||||
"space-y-4"}>
|
isGridLayout
|
||||||
|
? "grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4"
|
||||||
|
: "space-y-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<Card
|
<Card
|
||||||
key={server.id}
|
key={server.id}
|
||||||
className={isGridLayout ?
|
className={
|
||||||
"h-full flex flex-col justify-between" :
|
isGridLayout
|
||||||
"w-full mb-4"}
|
? "h-full flex flex-col justify-between"
|
||||||
|
: "w-full mb-4"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<CardTitle className="text-2xl font-bold">{server.name}</CardTitle>
|
<CardTitle className="text-2xl font-bold">
|
||||||
<CardDescription className={`text-sm mt-1 grid gap-y-1 ${isGridLayout ? "grid-cols-1" : "grid-cols-2 gap-x-4"}`}>
|
{server.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
className={`text-sm mt-1 grid gap-y-1 ${
|
||||||
|
isGridLayout
|
||||||
|
? "grid-cols-1"
|
||||||
|
: "grid-cols-2 gap-x-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<MonitorCog className="h-4 w-4 text-muted-foreground" />
|
<MonitorCog className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>OS:</b> {server.os || '-'}</span>
|
<span>
|
||||||
|
<b>OS:</b> {server.os || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<FileDigit className="h-4 w-4 text-muted-foreground" />
|
<FileDigit className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>IP:</b> {server.ip || 'Nicht angegeben'}</span>
|
<span>
|
||||||
|
<b>IP:</b> {server.ip || "Not set"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-full pt-2 pb-2">
|
<div className="col-span-full pt-2 pb-2">
|
||||||
@@ -378,19 +529,27 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>CPU:</b> {server.cpu || '-'}</span>
|
<span>
|
||||||
|
<b>CPU:</b> {server.cpu || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<Microchip className="h-4 w-4 text-muted-foreground" />
|
<Microchip className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>GPU:</b> {server.gpu || '-'}</span>
|
<span>
|
||||||
|
<b>GPU:</b> {server.gpu || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>RAM:</b> {server.ram || '-'}</span>
|
<span>
|
||||||
|
<b>RAM:</b> {server.ram || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-foreground/80">
|
<div className="flex items-center gap-2 text-foreground/80">
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
<span><b>Disk:</b> {server.disk || '-'}</span>
|
<span>
|
||||||
|
<b>Disk:</b> {server.disk || "-"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,17 +561,20 @@ export default function Dashboard() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 w-full"
|
className="gap-2 w-full"
|
||||||
onClick={() => window.open(server.url, "_blank")}
|
onClick={() =>
|
||||||
|
window.open(server.url, "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Link className="h-4 w-4" />
|
<Link className="h-4 w-4" />
|
||||||
Open Management URL
|
Open Management URL
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-10"
|
className="h-9 w-9"
|
||||||
onClick={() => deleteApplication(server.id)}
|
onClick={() => deleteApplication(server.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -421,7 +583,7 @@ export default function Dashboard() {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-10"
|
className="h-9 w-9"
|
||||||
onClick={() => openEditDialog(server)}
|
onClick={() => openEditDialog(server)}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
@@ -429,17 +591,28 @@ export default function Dashboard() {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Edit Server</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
Edit Server
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs
|
||||||
|
defaultValue="general"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">
|
||||||
<TabsTrigger value="hardware">Hardware</TabsTrigger>
|
General
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="hardware">
|
||||||
|
Hardware
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="general">
|
<TabsContent value="general">
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="editOs">Operating System</Label>
|
<Label htmlFor="editOs">
|
||||||
|
Operating System
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editOs}
|
value={editOs}
|
||||||
onValueChange={setEditOs}
|
onValueChange={setEditOs}
|
||||||
@@ -448,30 +621,44 @@ export default function Dashboard() {
|
|||||||
<SelectValue placeholder="Select OS" />
|
<SelectValue placeholder="Select OS" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Windows">Windows</SelectItem>
|
<SelectItem value="Windows">
|
||||||
<SelectItem value="Linux">Linux</SelectItem>
|
Windows
|
||||||
<SelectItem value="MacOS">MacOS</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="Linux">
|
||||||
|
Linux
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MacOS">
|
||||||
|
MacOS
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="editIp">IP Adress</Label>
|
<Label htmlFor="editIp">
|
||||||
|
IP Adress
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="editIp"
|
id="editIp"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. 192.168.100.2"
|
placeholder="e.g. 192.168.100.2"
|
||||||
value={editIp}
|
value={editIp}
|
||||||
onChange={(e) => setEditIp(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditIp(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="editUrl">Management URL</Label>
|
<Label htmlFor="editUrl">
|
||||||
|
Management URL
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="editUrl"
|
id="editUrl"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. https://proxmox.server1.com"
|
placeholder="e.g. https://proxmox.server1.com"
|
||||||
value={editUrl}
|
value={editUrl}
|
||||||
onChange={(e) => setEditUrl(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditUrl(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +671,9 @@ export default function Dashboard() {
|
|||||||
<Input
|
<Input
|
||||||
id="editCpu"
|
id="editCpu"
|
||||||
value={editCpu}
|
value={editCpu}
|
||||||
onChange={(e) => setEditCpu(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditCpu(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
@@ -492,7 +681,9 @@ export default function Dashboard() {
|
|||||||
<Input
|
<Input
|
||||||
id="editGpu"
|
id="editGpu"
|
||||||
value={editGpu}
|
value={editGpu}
|
||||||
onChange={(e) => setEditGpu(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditGpu(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
@@ -500,15 +691,21 @@ export default function Dashboard() {
|
|||||||
<Input
|
<Input
|
||||||
id="editRam"
|
id="editRam"
|
||||||
value={editRam}
|
value={editRam}
|
||||||
onChange={(e) => setEditRam(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditRam(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full items-center gap-1.5">
|
<div className="grid w-full items-center gap-1.5">
|
||||||
<Label htmlFor="editDisk">Disk</Label>
|
<Label htmlFor="editDisk">
|
||||||
|
Disk
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="editDisk"
|
id="editDisk"
|
||||||
value={editDisk}
|
value={editDisk}
|
||||||
onChange={(e) => setEditDisk(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditDisk(e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,40 +717,52 @@ export default function Dashboard() {
|
|||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<Button onClick={edit}>Save</Button>
|
<Button onClick={edit}>Save</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
:
|
) : (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className='inline-block' role='status' aria-label='loading'>
|
<div className="inline-block" role="status" aria-label="loading">
|
||||||
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
<svg
|
||||||
<g clip-path='url(#clip0_9023_61563)'>
|
className="w-6 h-6 stroke-white animate-spin "
|
||||||
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_9023_61563)">
|
||||||
|
<path
|
||||||
|
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
|
||||||
|
stroke="stroke-current"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
className="my-path"
|
||||||
|
></path>
|
||||||
</g>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id='clip0_9023_61563'>
|
<clipPath id="clip0_9023_61563">
|
||||||
<rect width='24' height='24' fill='white'></rect>
|
<rect width="24" height="24" fill="white"></rect>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='sr-only'>Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
<div className="pt-4">
|
<div className="pt-4 pb-4">
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
href="#"
|
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
isActive={currentPage > 1}
|
isActive={currentPage > 1}
|
||||||
|
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
@@ -563,9 +772,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
href="#"
|
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
isActive={currentPage < maxPage}
|
isActive={currentPage < maxPage}
|
||||||
|
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
@@ -574,5 +783,5 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -21,15 +21,121 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { AlertCircle, Check, Palette, User } from "lucide-react";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState<string>("")
|
||||||
|
const [password, setPassword] = useState<string>("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState<string>("")
|
||||||
|
const [oldPassword, setOldPassword] = useState<string>("")
|
||||||
|
|
||||||
|
const [emailError, setEmailError] = useState<string>("")
|
||||||
|
const [passwordError, setPasswordError] = useState<string>("")
|
||||||
|
const [emailErrorVisible, setEmailErrorVisible] = useState<boolean>(false)
|
||||||
|
const [passwordErrorVisible, setPasswordErrorVisible] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
|
||||||
|
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const changeEmail = async () => {
|
||||||
|
setEmailErrorVisible(false);
|
||||||
|
setEmailSuccess(false);
|
||||||
|
setEmailError("");
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
setEmailError("Email is required");
|
||||||
|
setEmailErrorVisible(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setEmailErrorVisible(false);
|
||||||
|
setEmailError("");
|
||||||
|
}
|
||||||
|
, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.post('/api/auth/edit_email', {
|
||||||
|
newEmail: email,
|
||||||
|
jwtToken: Cookies.get('token')
|
||||||
|
});
|
||||||
|
setEmailSuccess(true);
|
||||||
|
setEmail("");
|
||||||
|
setTimeout(() => {
|
||||||
|
setEmailSuccess(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
setEmailError(error.response.data.error);
|
||||||
|
setEmailErrorVisible(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setEmailErrorVisible(false);
|
||||||
|
setEmailError("");
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePassword = async () => {
|
||||||
|
try {
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setPasswordError("Passwords do not match");
|
||||||
|
setPasswordErrorVisible(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordErrorVisible(false);
|
||||||
|
setPasswordError("");
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!oldPassword || !password || !confirmPassword) {
|
||||||
|
setPasswordError("All fields are required");
|
||||||
|
setPasswordErrorVisible(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordErrorVisible(false);
|
||||||
|
setPasswordError("");
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post('/api/auth/edit_password', {
|
||||||
|
oldPassword: oldPassword,
|
||||||
|
newPassword: password,
|
||||||
|
jwtToken: Cookies.get('token')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
setPasswordSuccess(true);
|
||||||
|
setPassword("");
|
||||||
|
setOldPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordSuccess(false);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setPasswordErrorVisible(true);
|
||||||
|
setPasswordError(error.response.data.error);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordErrorVisible(false);
|
||||||
|
setPasswordError("");
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
@@ -50,19 +156,128 @@ export default function Settings() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="pl-4 pr-4">
|
<div className="p-6">
|
||||||
<span className="text-2xl font-semibold">Settings</span>
|
<div className="pb-4">
|
||||||
<div className="pt-4">
|
<span className="text-3xl font-bold">Settings</span>
|
||||||
<Card className="w-full mb-4 relative">
|
</div>
|
||||||
<CardHeader>
|
<div className="grid gap-6">
|
||||||
<span className="text-xl font-bold">Theme</span>
|
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||||
<Select
|
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||||
value={theme}
|
<div className="flex items-center gap-2">
|
||||||
onValueChange={(value: string) => setTheme(value)}
|
<User className="h-5 w-5 text-primary" />
|
||||||
>
|
<h2 className="text-xl font-semibold">User Settings</h2>
|
||||||
<SelectTrigger className="w-full [&_svg]:hidden">
|
</div>
|
||||||
|
</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>
|
<SelectValue>
|
||||||
{(theme ?? 'system').charAt(0).toUpperCase() + (theme ?? 'system').slice(1)}
|
{(theme ?? "system").charAt(0).toUpperCase() + (theme ?? "system").slice(1)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -71,11 +286,12 @@ export default function Settings() {
|
|||||||
<SelectItem value="system">System</SelectItem>
|
<SelectItem value="system">System</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
296
app/dashboard/uptime/Uptime.tsx
Normal file
296
app/dashboard/uptime/Uptime.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
app/dashboard/uptime/page.tsx
Normal file
59
app/dashboard/uptime/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Uptime from "./Uptime";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isAuthChecked, setIsAuthChecked] = useState(false);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/");
|
||||||
|
} else {
|
||||||
|
const checkToken = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post("/api/auth/validate", {
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
setIsValid(true);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Cookies.remove("token");
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkToken();
|
||||||
|
}
|
||||||
|
setIsAuthChecked(true);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (!isAuthChecked) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className='inline-block' role='status' aria-label='loading'>
|
||||||
|
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||||
|
<g clip-path='url(#clip0_9023_61563)'>
|
||||||
|
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' stroke-width='1.4' stroke-linecap='round' className='my-path'></path>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id='clip0_9023_61563'>
|
||||||
|
<rect width='24' height='24' fill='white'></rect>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<span className='sr-only'>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid ? <Uptime /> : null;
|
||||||
|
}
|
||||||
178
app/page.tsx
178
app/page.tsx
@@ -1,108 +1,148 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import Cookies from "js-cookie"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import axios from "axios"
|
||||||
|
import { AlertCircle, KeyRound, Mail, User } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { AlertCircle } from "lucide-react"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from "@/components/ui/alert"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState("")
|
||||||
const router = useRouter();
|
const [rememberMe, setRememberMe] = useState(false)
|
||||||
const [error, setError] = useState('');
|
const router = useRouter()
|
||||||
const [errorVisible, setErrorVisible] = useState(false);
|
const [error, setError] = useState("")
|
||||||
|
const [errorVisible, setErrorVisible] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = Cookies.get('token');
|
const token = Cookies.get("token")
|
||||||
if (token) {
|
if (token) {
|
||||||
router.push('/dashboard');
|
router.push("/dashboard")
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router])
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
token: string;
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
try {
|
if (!username || !password) {
|
||||||
const response = await axios.post('/api/auth/login', { username, password });
|
setError("Please enter both email and password")
|
||||||
const { token } = response.data as LoginResponse;
|
|
||||||
Cookies.set('token', token);
|
|
||||||
router.push('/dashboard');
|
|
||||||
} catch (error: any) {
|
|
||||||
setError(error.response.data.error);
|
|
||||||
setErrorVisible(true)
|
setErrorVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const response = await axios.post("/api/auth/login", { username, password })
|
||||||
|
const { token } = response.data as LoginResponse
|
||||||
|
|
||||||
|
const cookieOptions = rememberMe ? { expires: 7 } : {}
|
||||||
|
Cookies.set("token", token, cookieOptions)
|
||||||
|
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.response?.data?.error || "Login failed. Please try again.")
|
||||||
|
setErrorVisible(true)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
login()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen items-center justify-center gap-6 ">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
|
||||||
<Card className="w-1/3">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<CardHeader>
|
<div className="text-center space-y-2">
|
||||||
<CardTitle className="text-2xl">Login</CardTitle>
|
<div className="flex justify-center">
|
||||||
<CardDescription>
|
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
Enter your Login data of the compose.yml file below to access
|
<KeyRound className="h-8 w-8 text-primary" />
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{errorVisible && (
|
|
||||||
<>
|
|
||||||
<div className="pb-4">
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{error}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
<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>
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="space-y-2">
|
||||||
<div className="grid gap-2">
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
<Label htmlFor="email">Email</Label>
|
Email
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="mail@example.com"
|
placeholder="mail@example.com"
|
||||||
required
|
className="pl-10"
|
||||||
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
</div>
|
</div>
|
||||||
<Input id="password" type="password" required placeholder="* * * * * * *" onChange={(e) => setPassword(e.target.value)}/>
|
|
||||||
|
<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>
|
||||||
<Button className="w-full" onClick={login}>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network } from "lucide-react"
|
import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network, Activity } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -18,8 +18,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Cookies from "js-cookie"
|
import Cookies from "js-cookie"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import packageJson from "@/package.json"
|
||||||
|
|
||||||
// Typdefinitionen
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
title: string
|
title: string
|
||||||
icon?: React.ComponentType<any>
|
icon?: React.ComponentType<any>
|
||||||
@@ -50,6 +50,11 @@ const data: { navMain: NavItem[] } = {
|
|||||||
icon: AppWindow,
|
icon: AppWindow,
|
||||||
url: "/dashboard/applications",
|
url: "/dashboard/applications",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Uptime",
|
||||||
|
icon: Activity,
|
||||||
|
url: "/dashboard/uptime",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Network",
|
title: "Network",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
@@ -83,7 +88,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<Image src="/logo.png" width={48} height={48} alt="Logo"/>
|
<Image src="/logo.png" width={48} height={48} alt="Logo"/>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="font-semibold">CoreControl</span>
|
<span className="font-semibold">CoreControl</span>
|
||||||
<span className="">v0.0.1</span>
|
<span className="">v{packageJson.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal 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 }
|
||||||
@@ -4,11 +4,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
LOGIN_EMAIL: "mail@example.com"
|
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
|
||||||
LOGIN_PASSWORD: "SecretPassword"
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
JWT_SECRET: RANDOM_SECRET
|
|
||||||
ACCOUNT_SECRET: RANDOM_SECRET
|
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- agent
|
- agent
|
||||||
@@ -16,7 +13,7 @@ services:
|
|||||||
agent:
|
agent:
|
||||||
image: haedlessdev/corecontrol-agent:latest
|
image: haedlessdev/corecontrol-agent:latest
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres?sslmode=require&schema=public"
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
|
|||||||
BIN
docs/.gitbook/assets/image.png
Normal file
BIN
docs/.gitbook/assets/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
27
docs/README.md
Normal file
27
docs/README.md
Normal 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
4
docs/SUMMARY.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Table of contents
|
||||||
|
|
||||||
|
* [CoreControl](README.md)
|
||||||
|
* [Installation](installation.md)
|
||||||
56
docs/installation.md
Normal file
56
docs/installation.md
Normal 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. 
|
||||||
|
|
||||||
|
You can then simply install and start the following Docker compose. Remember that you have to generate a JWT\_SECRET beforehand.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: haedlessdev/corecontrol:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
|
||||||
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- agent
|
||||||
|
|
||||||
|
agent:
|
||||||
|
image: haedlessdev/corecontrol-agent:latest
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
Now start the application:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**The default login is:**
|
||||||
|
|
||||||
|
E-Mail: [admin@example.com](mailto:admin@example.com)\
|
||||||
|
Password: admin
|
||||||
|
|
||||||
|
_Be sure to set your own password and customize the e-mail, otherwise this poses a security risk!_
|
||||||
827
package-lock.json
generated
827
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "corecontrol",
|
"name": "corecontrol",
|
||||||
"version": "0.1.0",
|
"version": "0.0.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"@prisma/extension-accelerate": "^1.3.0",
|
"@prisma/extension-accelerate": "^1.3.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||||
@@ -21,18 +22,20 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.4",
|
"@radix-ui/react-tabs": "^1.1.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@types/axios": "^0.9.36",
|
"@types/axios": "^0.9.36",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@xyflow/react": "^12.5.5",
|
"@xyflow/react": "^12.5.5",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"next": "15.3.0",
|
"next": "15.3.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.14.1",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
|
|||||||
11
prisma/migrations/20250414173201_user_model/migration.sql
Normal file
11
prisma/migrations/20250414173201_user_model/migration.sql
Normal 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");
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -25,6 +25,13 @@ model application {
|
|||||||
online Boolean @default(true)
|
online Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model uptime_history {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
applicationId Int @default(1)
|
||||||
|
online Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
model server {
|
model server {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@@ -41,3 +48,9 @@ model settings {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uptime_checks Boolean @default(true)
|
uptime_checks Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model user {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
}
|
||||||
BIN
public/cover.png
Normal file
BIN
public/cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Reference in New Issue
Block a user