mirror of
https://github.com/crocofied/CoreControl.git
synced 2025-12-24 10:56:54 +00:00
Revert "cleanup v2"
This reverts commit 8b82578809528b7163ea7003d0a4734d93522263.
This commit is contained in:
parent
8b82578809
commit
94ba5e20c6
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
agent/
|
||||
.next
|
||||
docs/
|
||||
screenshots/
|
||||
43
.github/CODE_OF_CONDUCT.md
vendored
Normal file
43
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery, and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project leaders are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [your email address or contact method here]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html), available at [https://www.contributor-covenant.org/](https://www.contributor-covenant.org/).
|
||||
8
.github/CONTRIBUTING.md
vendored
Normal file
8
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# How to Contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a new branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
Please make sure your code passes all tests and follows the style guide.
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github: crocofied
|
||||
buy_me_a_coffee: corecontrol
|
||||
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
name: 🐞 - Bug Report
|
||||
description: Report a problem or unexpected behavior.
|
||||
title: "[Bug]: "
|
||||
labels: ["unverified bug"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug! Please fill out the information below.
|
||||
- type: input
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the issue you encountered.
|
||||
placeholder: Tell us what happened...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the steps needed to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The version of the CoreControl you are using.
|
||||
placeholder: e.g. v0.0.10
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: The logs of the docker containers, if necessary.
|
||||
placeholder: |
|
||||
```
|
||||
[logs]
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Any extra details...
|
||||
41
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: ✨ - Feature Request
|
||||
description: Suggest a new idea or enhancement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for suggesting a feature! Please provide as much detail as possible.
|
||||
- type: input
|
||||
id: feature-summary
|
||||
attributes:
|
||||
label: Feature Summary
|
||||
description: Short summary of the feature you are requesting.
|
||||
placeholder: A short and clear description...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: Why do you need this feature? What problem does it solve?
|
||||
placeholder: Describe the use case...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: possible-solution
|
||||
attributes:
|
||||
label: Possible Solution
|
||||
description: Suggest an idea for how the feature could be implemented (optional).
|
||||
placeholder: Maybe something like this...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or examples here.
|
||||
placeholder: Other relevant information...
|
||||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
## Description
|
||||
Please explain the changes you made.
|
||||
|
||||
## Checklist
|
||||
- [ ] I have tested the code
|
||||
- [ ] I have updated the documentation if necessary
|
||||
- [ ] I followed the project's coding guidelines
|
||||
1
.github/config.yml
vendored
Normal file
1
.github/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
|
||||
# Vitepress
|
||||
docs/.vitepress/cache
|
||||
docs/node_modules/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@ -0,0 +1,53 @@
|
||||
# Builder Stage
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
||||
|
||||
ARG TARGETARCH # Automatically set by Buildx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY ./prisma ./prisma
|
||||
|
||||
# Set PRISMA_CLI_BINARY_TARGETS based on TARGETARCH and install dependencies
|
||||
RUN case ${TARGETARCH} in \
|
||||
"amd64") export PRISMA_CLI_BINARY_TARGETS="linux-musl-openssl-3.0.x" ;; \
|
||||
"arm64") export PRISMA_CLI_BINARY_TARGETS="linux-musl-arm64-openssl-3.0.x" ;; \
|
||||
"arm") export PRISMA_CLI_BINARY_TARGETS="linux-musl-arm-openssl-3.0.x" ;; \
|
||||
*) echo "Unsupported ARCH: ${TARGETARCH}" && exit 1 ;; \
|
||||
esac && \
|
||||
npm install && \
|
||||
npx prisma generate
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production Stage
|
||||
FROM --platform=$TARGETPLATFORM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Copy built assets and dependencies from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.js* ./
|
||||
|
||||
# Prune dev dependencies
|
||||
RUN npm prune --production
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Dynamically set PRISMA_CLI_BINARY_TARGETS based on runtime architecture and start
|
||||
CMD ["sh", "-c", "\
|
||||
export PRISMA_CLI_BINARY_TARGETS=$(case $(uname -m) in \
|
||||
x86_64) echo linux-musl-openssl-3.0.x ;; \
|
||||
aarch64) echo linux-musl-arm64-openssl-3.0.x ;; \
|
||||
armv7l) echo linux-musl-arm-openssl-3.0.x ;; \
|
||||
*) echo \"Unsupported architecture: $(uname -m)\" && exit 1 ;; \
|
||||
esac) && \
|
||||
npx prisma migrate deploy && \
|
||||
npm start"]
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
Copyright 2025 Damian Bergemann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
115
README.md
Normal file
115
README.md
Normal file
@ -0,0 +1,115 @@
|
||||
|
||||

|
||||
|
||||
|
||||
# CoreControl
|
||||
|
||||
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.
|
||||
|
||||
[](https://www.buymeacoffee.com/corecontrol)
|
||||
[](https://github.com/sponsors/crocofied)
|
||||
|
||||
## 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.
|
||||
|
||||
## Screenshots
|
||||
Login Page:
|
||||

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

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

|
||||
|
||||
Server Detail Page
|
||||

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

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

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

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

|
||||
|
||||
## Roadmap
|
||||
- [X] Edit Applications, Applications searchbar
|
||||
- [X] Uptime History
|
||||
- [X] Notifications
|
||||
- [X] Simple Server Monitoring
|
||||
- [ ] Improved Network Flowchart with custom elements (like Network switches)
|
||||
- [ ] Advanced Settings (Disable Uptime Tracking & more)
|
||||
|
||||
## Deployment
|
||||
|
||||
Simply run this compose.yml:
|
||||
```yml
|
||||
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"
|
||||
|
||||
agent:
|
||||
image: haedlessdev/corecontrol-agent:latest
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
#### Default Login
|
||||
__E-Mail:__ admin@example.com\
|
||||
__Password:__ admin
|
||||
|
||||
## Tech Stack & Credits
|
||||
|
||||
The application is build with:
|
||||
- Next.js & Typescript
|
||||
- Go (used for the agent)
|
||||
- Tailwindcss with [shadcn](shadcn.com)
|
||||
- PostgreSQL with [Prisma ORM](https://www.prisma.io/)
|
||||
- Icons by [Lucide](https://lucide.dev/)
|
||||
- Flowcharts by [React Flow](https://reactflow.dev/)
|
||||
- Application icons by [selfh.st/icons](https://selfh.st/icons)
|
||||
- Monitoring Tool by [Glances](https://github.com/nicolargo/glances)
|
||||
- and a lot of love ❤️
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#crocofied/CoreControl&Date)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://github.com/crocofied/CoreControl/blob/main/LICENSE).
|
||||
1
agent/.dockerignore
Normal file
1
agent/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
.env
|
||||
45
agent/Dockerfile
Normal file
45
agent/Dockerfile
Normal file
@ -0,0 +1,45 @@
|
||||
# --- Build Stage ---
|
||||
# Multi-Arch Builder mit expliziter Plattform-Angabe
|
||||
FROM --platform=$BUILDPLATFORM golang:1.19-alpine AS builder
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Cross-Compile für Zielarchitektur
|
||||
RUN go build -ldflags="-w -s" -o app ./cmd/agent
|
||||
|
||||
# --- Run Stage ---
|
||||
# Multi-Arch Laufzeit-Image
|
||||
FROM alpine:latest
|
||||
|
||||
# Notwendig für TLS/SSL-Zertifikate
|
||||
RUN apk --no-cache add ca-certificates gcompat
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
COPY --from=builder /app/app .
|
||||
|
||||
# Security Hardening
|
||||
USER nobody:nobody
|
||||
ENV GOMAXPROCS=1
|
||||
|
||||
CMD ["./app"]
|
||||
|
||||
# - - BUILD COMMAND - -
|
||||
# docker buildx build \
|
||||
# --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
# -t haedlessdev/corecontrol-agent:1.0.0 \
|
||||
# -t haedlessdev/corecontrol-agent:latest \
|
||||
# --push \
|
||||
# .
|
||||
111
agent/cmd/agent/main.go
Normal file
111
agent/cmd/agent/main.go
Normal file
@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/corecontrol/agent/internal/app"
|
||||
"github.com/corecontrol/agent/internal/database"
|
||||
"github.com/corecontrol/agent/internal/notifications"
|
||||
"github.com/corecontrol/agent/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize database
|
||||
db, err := database.InitDB()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Database initialization failed: %v\n", err))
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize notification sender
|
||||
notifSender := notifications.NewNotificationSender()
|
||||
|
||||
// Initial load of notifications
|
||||
notifs, err := database.LoadNotifications(db)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to load notifications: %v", err))
|
||||
}
|
||||
notifSender.UpdateNotifications(notifs)
|
||||
|
||||
// Reload notification configs every minute
|
||||
go func() {
|
||||
reloadTicker := time.NewTicker(time.Minute)
|
||||
defer reloadTicker.Stop()
|
||||
|
||||
for range reloadTicker.C {
|
||||
newNotifs, err := database.LoadNotifications(db)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to reload notifications: %v\n", err)
|
||||
continue
|
||||
}
|
||||
notifSender.UpdateNotifications(newNotifs)
|
||||
fmt.Println("Reloaded notification configurations")
|
||||
}
|
||||
}()
|
||||
|
||||
// Clean up old entries hourly
|
||||
go func() {
|
||||
deletionTicker := time.NewTicker(time.Hour)
|
||||
defer deletionTicker.Stop()
|
||||
|
||||
for range deletionTicker.C {
|
||||
if err := database.DeleteOldEntries(db); err != nil {
|
||||
fmt.Printf("Error deleting old entries: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Check for test notifications every 10 seconds
|
||||
go func() {
|
||||
testNotifTicker := time.NewTicker(10 * time.Second)
|
||||
defer testNotifTicker.Stop()
|
||||
|
||||
for range testNotifTicker.C {
|
||||
notifs := notifSender.GetNotifications()
|
||||
database.CheckAndSendTestNotifications(db, notifs, notifSender.SendSpecificNotification)
|
||||
}
|
||||
}()
|
||||
|
||||
// HTTP clients
|
||||
appClient := &http.Client{
|
||||
Timeout: 4 * time.Second,
|
||||
}
|
||||
|
||||
serverClient := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Server monitoring every 5 seconds
|
||||
go func() {
|
||||
serverTicker := time.NewTicker(5 * time.Second)
|
||||
defer serverTicker.Stop()
|
||||
|
||||
for range serverTicker.C {
|
||||
servers, err := database.GetServers(db)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting servers: %v\n", err)
|
||||
continue
|
||||
}
|
||||
server.MonitorServers(db, serverClient, servers, notifSender)
|
||||
}
|
||||
}()
|
||||
|
||||
// Application monitoring every 10 seconds
|
||||
appTicker := time.NewTicker(time.Second)
|
||||
defer appTicker.Stop()
|
||||
|
||||
for now := range appTicker.C {
|
||||
if now.Second()%10 != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
apps, err := database.GetApplications(db)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting applications: %v\n", err)
|
||||
continue
|
||||
}
|
||||
app.MonitorApplications(db, appClient, apps, notifSender)
|
||||
}
|
||||
}
|
||||
22
agent/go.mod
Normal file
22
agent/go.mod
Normal file
@ -0,0 +1,22 @@
|
||||
module github.com/corecontrol/agent
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v4 v4.18.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.14.0 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgtype v1.14.0 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
)
|
||||
205
agent/go.sum
Normal file
205
agent/go.sum
Normal file
@ -0,0 +1,205 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.14.0 h1:vrbA9Ud87g6JdFWkHTJXppVce58qPIdP7N8y0Ml/A7Q=
|
||||
github.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0=
|
||||
github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
|
||||
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.18.1 h1:YP7G1KABtKpB5IHrO9vYwSrCOhs7p3uqhvhhQBptya0=
|
||||
github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
130
agent/internal/app/monitor.go
Normal file
130
agent/internal/app/monitor.go
Normal file
@ -0,0 +1,130 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/corecontrol/agent/internal/models"
|
||||
"github.com/corecontrol/agent/internal/notifications"
|
||||
)
|
||||
|
||||
// MonitorApplications checks and updates the status of all applications
|
||||
func MonitorApplications(db *sql.DB, client *http.Client, apps []models.Application, notifSender *notifications.NotificationSender) {
|
||||
var notificationTemplate string
|
||||
err := db.QueryRow("SELECT notification_text_application FROM settings LIMIT 1").Scan(¬ificationTemplate)
|
||||
if err != nil || notificationTemplate == "" {
|
||||
notificationTemplate = "The application !name (!url) went !status!"
|
||||
}
|
||||
|
||||
for _, app := range apps {
|
||||
logPrefix := fmt.Sprintf("[App %s (%s)]", app.Name, app.PublicURL)
|
||||
fmt.Printf("%s Checking...\n", logPrefix)
|
||||
|
||||
// Determine which URL to use for monitoring
|
||||
checkURL := app.PublicURL
|
||||
if app.UptimeCheckURL != "" {
|
||||
checkURL = app.UptimeCheckURL
|
||||
fmt.Printf("%s Using custom uptime check URL: %s\n", logPrefix, checkURL)
|
||||
}
|
||||
|
||||
parsedURL, parseErr := url.Parse(checkURL)
|
||||
if parseErr != nil {
|
||||
fmt.Printf("%s Invalid URL: %v\n", logPrefix, parseErr)
|
||||
continue
|
||||
}
|
||||
|
||||
hostIsIP := isIPAddress(parsedURL.Hostname())
|
||||
var isOnline bool
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
isOnline = resp.StatusCode >= 200 && resp.StatusCode < 400
|
||||
fmt.Printf("%s Response status: %d\n", logPrefix, resp.StatusCode)
|
||||
} else {
|
||||
fmt.Printf("%s Connection error: %v\n", logPrefix, err)
|
||||
|
||||
if hostIsIP {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
var certErr x509.HostnameError
|
||||
var unknownAuthErr x509.UnknownAuthorityError
|
||||
if errors.As(urlErr.Err, &certErr) || errors.As(urlErr.Err, &unknownAuthErr) {
|
||||
fmt.Printf("%s Ignoring TLS error for IP, marking as online\n", logPrefix)
|
||||
isOnline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isOnline != app.Online {
|
||||
status := "offline"
|
||||
if isOnline {
|
||||
status = "online"
|
||||
}
|
||||
|
||||
message := strings.ReplaceAll(notificationTemplate, "!name", app.Name)
|
||||
message = strings.ReplaceAll(message, "!url", app.PublicURL)
|
||||
message = strings.ReplaceAll(message, "!status", status)
|
||||
|
||||
notifSender.SendNotifications(message)
|
||||
}
|
||||
|
||||
// Update application status in database
|
||||
updateApplicationStatus(db, app.ID, isOnline)
|
||||
|
||||
// Add entry to uptime history
|
||||
addUptimeHistoryEntry(db, app.ID, isOnline)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to update application status
|
||||
func updateApplicationStatus(db *sql.DB, appID int, online bool) {
|
||||
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer dbCancel()
|
||||
|
||||
_, err := db.ExecContext(dbCtx,
|
||||
`UPDATE application SET online = $1 WHERE id = $2`,
|
||||
online, appID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("DB update failed for app ID %d: %v\n", appID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to add uptime history entry
|
||||
func addUptimeHistoryEntry(db *sql.DB, appID int, online bool) {
|
||||
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer dbCancel()
|
||||
|
||||
_, err := db.ExecContext(dbCtx,
|
||||
`INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
|
||||
appID, online,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("History insert failed for app ID %d: %v\n", appID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a host is an IP address
|
||||
func isIPAddress(host string) bool {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil
|
||||
}
|
||||
198
agent/internal/database/database.go
Normal file
198
agent/internal/database/database.go
Normal file
@ -0,0 +1,198 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/corecontrol/agent/internal/models"
|
||||
|
||||
_ "github.com/jackc/pgx/v4/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// InitDB initializes the database connection
|
||||
func InitDB() (*sql.DB, error) {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
fmt.Println("No env vars found")
|
||||
}
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database connection failed: %v", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// GetApplications fetches all applications with public URLs
|
||||
func GetApplications(db *sql.DB) ([]models.Application, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, name, "publicURL", online, "uptimecheckUrl" FROM application WHERE "publicURL" IS NOT NULL`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching applications: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []models.Application
|
||||
for rows.Next() {
|
||||
var app models.Application
|
||||
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online, &app.UptimeCheckURL); err != nil {
|
||||
fmt.Printf("Error scanning row: %v\n", err)
|
||||
continue
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// GetServers fetches all servers with monitoring enabled
|
||||
func GetServers(db *sql.DB) ([]models.Server, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, name, monitoring, "monitoringURL", online, "cpuUsage", "ramUsage", "diskUsage"
|
||||
FROM server WHERE monitoring = true`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching servers: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var server models.Server
|
||||
if err := rows.Scan(
|
||||
&server.ID, &server.Name, &server.Monitoring, &server.MonitoringURL,
|
||||
&server.Online, &server.CpuUsage, &server.RamUsage, &server.DiskUsage,
|
||||
); err != nil {
|
||||
fmt.Printf("Error scanning server row: %v\n", err)
|
||||
continue
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// LoadNotifications loads all enabled notifications
|
||||
func LoadNotifications(db *sql.DB) ([]models.Notification, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
|
||||
"telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken",
|
||||
"pushoverUrl", "pushoverToken", "pushoverUser", "echobellURL"
|
||||
FROM notification
|
||||
WHERE enabled = true`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []models.Notification
|
||||
for rows.Next() {
|
||||
var n models.Notification
|
||||
if err := rows.Scan(
|
||||
&n.ID, &n.Enabled, &n.Type,
|
||||
&n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo,
|
||||
&n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook,
|
||||
&n.GotifyUrl, &n.GotifyToken, &n.NtfyUrl, &n.NtfyToken,
|
||||
&n.PushoverUrl, &n.PushoverToken, &n.PushoverUser, &n.EchobellURL,
|
||||
); err != nil {
|
||||
fmt.Printf("Error scanning notification: %v\n", err)
|
||||
continue
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// DeleteOldEntries removes entries older than 30 days
|
||||
func DeleteOldEntries(db *sql.DB) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Delete old uptime history entries
|
||||
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)
|
||||
|
||||
// Delete old server history entries
|
||||
res, err = db.ExecContext(ctx,
|
||||
`DELETE FROM server_history WHERE "createdAt" < now() - interval '30 days'`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ = res.RowsAffected()
|
||||
fmt.Printf("Deleted %d old entries from server_history\n", affected)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateServerStatus updates a server's status and metrics
|
||||
func UpdateServerStatus(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage float64, uptime string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE server SET online = $1, "cpuUsage" = $2::float8, "ramUsage" = $3::float8, "diskUsage" = $4::float8, "uptime" = $5
|
||||
WHERE id = $6`,
|
||||
online, cpuUsage, ramUsage, diskUsage, uptime, serverID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckAndSendTestNotifications checks for and processes test notifications
|
||||
func CheckAndSendTestNotifications(db *sql.DB, notifications []models.Notification, sendFunc func(models.Notification, string)) {
|
||||
// Query for test notifications
|
||||
rows, err := db.Query(`SELECT tn.id, tn."notificationId" FROM test_notification tn`)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching test notifications: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Process each test notification
|
||||
var testIds []int
|
||||
for rows.Next() {
|
||||
var id, notificationId int
|
||||
if err := rows.Scan(&id, ¬ificationId); err != nil {
|
||||
fmt.Printf("Error scanning test notification: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to list of IDs to delete
|
||||
testIds = append(testIds, id)
|
||||
|
||||
// Find the notification configuration
|
||||
for _, n := range notifications {
|
||||
if n.ID == notificationId {
|
||||
// Send test notification
|
||||
fmt.Printf("Sending test notification to notification ID %d\n", notificationId)
|
||||
sendFunc(n, "Test notification from CoreControl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete processed test notifications
|
||||
if len(testIds) > 0 {
|
||||
for _, id := range testIds {
|
||||
_, err := db.Exec(`DELETE FROM test_notification WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting test notification (ID: %d): %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
agent/internal/models/models.go
Normal file
98
agent/internal/models/models.go
Normal file
@ -0,0 +1,98 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
ID int
|
||||
Name string
|
||||
PublicURL string
|
||||
Online bool
|
||||
UptimeCheckURL string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int
|
||||
Name string
|
||||
Monitoring bool
|
||||
MonitoringURL sql.NullString
|
||||
Online bool
|
||||
CpuUsage sql.NullFloat64
|
||||
RamUsage sql.NullFloat64
|
||||
DiskUsage sql.NullFloat64
|
||||
GpuUsage sql.NullFloat64
|
||||
Temp sql.NullFloat64
|
||||
Uptime sql.NullString
|
||||
}
|
||||
|
||||
type CPUResponse struct {
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
type MemoryResponse struct {
|
||||
Active int64 `json:"active"`
|
||||
Available int64 `json:"available"`
|
||||
Buffers int64 `json:"buffers"`
|
||||
Cached int64 `json:"cached"`
|
||||
Free int64 `json:"free"`
|
||||
Inactive int64 `json:"inactive"`
|
||||
Percent float64 `json:"percent"`
|
||||
Shared int64 `json:"shared"`
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type FSResponse []struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
MntPoint string `json:"mnt_point"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
type UptimeResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type GPUResponse struct {
|
||||
Proc float64 `json:"proc"`
|
||||
}
|
||||
|
||||
type TemperatureResponse struct {
|
||||
Composite []struct {
|
||||
Label string `json:"label"`
|
||||
Unit string `json:"unit"`
|
||||
Value float64 `json:"value"`
|
||||
Warning float64 `json:"warning"`
|
||||
Critical float64 `json:"critical"`
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
} `json:"Composite"`
|
||||
}
|
||||
|
||||
type TempResponse struct {
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID int
|
||||
Enabled bool
|
||||
Type string
|
||||
SMTPHost sql.NullString
|
||||
SMTPPort sql.NullInt64
|
||||
SMTPFrom sql.NullString
|
||||
SMTPUser sql.NullString
|
||||
SMTPPass sql.NullString
|
||||
SMTPSecure sql.NullBool
|
||||
SMTPTo sql.NullString
|
||||
TelegramChatID sql.NullString
|
||||
TelegramToken sql.NullString
|
||||
DiscordWebhook sql.NullString
|
||||
GotifyUrl sql.NullString
|
||||
GotifyToken sql.NullString
|
||||
NtfyUrl sql.NullString
|
||||
NtfyToken sql.NullString
|
||||
PushoverUrl sql.NullString
|
||||
PushoverToken sql.NullString
|
||||
PushoverUser sql.NullString
|
||||
EchobellURL sql.NullString
|
||||
}
|
||||
266
agent/internal/notifications/notifications.go
Normal file
266
agent/internal/notifications/notifications.go
Normal file
@ -0,0 +1,266 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/corecontrol/agent/internal/models"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type NotificationSender struct {
|
||||
notifications []models.Notification
|
||||
notifMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewNotificationSender creates a new notification sender
|
||||
func NewNotificationSender() *NotificationSender {
|
||||
return &NotificationSender{
|
||||
notifications: []models.Notification{},
|
||||
notifMutex: sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateNotifications updates the stored notifications
|
||||
func (ns *NotificationSender) UpdateNotifications(notifs []models.Notification) {
|
||||
ns.notifMutex.Lock()
|
||||
defer ns.notifMutex.Unlock()
|
||||
|
||||
copyDst := make([]models.Notification, len(notifs))
|
||||
copy(copyDst, notifs)
|
||||
ns.notifications = copyDst
|
||||
}
|
||||
|
||||
// GetNotifications returns a safe copy of current notifications
|
||||
func (ns *NotificationSender) GetNotifications() []models.Notification {
|
||||
ns.notifMutex.RLock()
|
||||
defer ns.notifMutex.RUnlock()
|
||||
|
||||
copyDst := make([]models.Notification, len(ns.notifications))
|
||||
copy(copyDst, ns.notifications)
|
||||
return copyDst
|
||||
}
|
||||
|
||||
// SendNotifications sends a message to all configured notifications
|
||||
func (ns *NotificationSender) SendNotifications(message string) {
|
||||
notifs := ns.GetNotifications()
|
||||
|
||||
for _, n := range notifs {
|
||||
ns.SendSpecificNotification(n, message)
|
||||
}
|
||||
}
|
||||
|
||||
// SendSpecificNotification sends a message to a specific notification
|
||||
func (ns *NotificationSender) SendSpecificNotification(n models.Notification, message string) {
|
||||
fmt.Println("Sending specific notification..." + n.Type)
|
||||
switch n.Type {
|
||||
case "smtp":
|
||||
if n.SMTPHost.Valid && n.SMTPTo.Valid {
|
||||
ns.sendEmail(n, message)
|
||||
}
|
||||
case "telegram":
|
||||
if n.TelegramToken.Valid && n.TelegramChatID.Valid {
|
||||
ns.sendTelegram(n, message)
|
||||
}
|
||||
case "discord":
|
||||
if n.DiscordWebhook.Valid {
|
||||
ns.sendDiscord(n, message)
|
||||
}
|
||||
case "gotify":
|
||||
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
|
||||
ns.sendGotify(n, message)
|
||||
}
|
||||
case "ntfy":
|
||||
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
|
||||
ns.sendNtfy(n, message)
|
||||
}
|
||||
case "pushover":
|
||||
if n.PushoverUrl.Valid && n.PushoverToken.Valid && n.PushoverUser.Valid {
|
||||
ns.sendPushover(n, message)
|
||||
}
|
||||
case "echobell":
|
||||
if n.EchobellURL.Valid {
|
||||
ns.sendEchobell(n, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a host is an IP address
|
||||
func (ns *NotificationSender) isIPAddress(host string) bool {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil
|
||||
}
|
||||
|
||||
// Individual notification methods
|
||||
func (ns *NotificationSender) sendEmail(n models.Notification, body string) {
|
||||
// Initialize SMTP dialer with host, port, user, pass
|
||||
d := gomail.NewDialer(
|
||||
n.SMTPHost.String,
|
||||
int(n.SMTPPort.Int64),
|
||||
n.SMTPUser.String,
|
||||
n.SMTPPass.String,
|
||||
)
|
||||
if n.SMTPSecure.Valid && n.SMTPSecure.Bool {
|
||||
d.SSL = true
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", n.SMTPFrom.String)
|
||||
m.SetHeader("To", n.SMTPTo.String)
|
||||
m.SetHeader("Subject", "Uptime Notification")
|
||||
m.SetBody("text/plain", body)
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
fmt.Printf("Email send failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendTelegram(n models.Notification, message string) {
|
||||
apiUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&text=%s",
|
||||
n.TelegramToken.String,
|
||||
n.TelegramChatID.String,
|
||||
message,
|
||||
)
|
||||
resp, err := http.Get(apiUrl)
|
||||
if err != nil {
|
||||
fmt.Printf("Telegram send failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendDiscord(n models.Notification, message string) {
|
||||
payload := fmt.Sprintf(`{"content": "%s"}`, message)
|
||||
req, err := http.NewRequest("POST", n.DiscordWebhook.String, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
fmt.Printf("Discord request creation failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Discord send failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendGotify(n models.Notification, message string) {
|
||||
baseURL := strings.TrimSuffix(n.GotifyUrl.String, "/")
|
||||
targetURL := fmt.Sprintf("%s/message", baseURL)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("message", message)
|
||||
form.Add("priority", "5")
|
||||
|
||||
req, err := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
fmt.Printf("Gotify: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("X-Gotify-Key", n.GotifyToken.String)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Gotify: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Gotify: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendNtfy(n models.Notification, message string) {
|
||||
fmt.Println("Sending Ntfy notification...")
|
||||
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
|
||||
|
||||
// Don't append a topic to the URL - the URL itself should have the correct endpoint
|
||||
requestURL := baseURL
|
||||
|
||||
// Send message directly as request body instead of JSON
|
||||
req, err := http.NewRequest("POST", requestURL, strings.NewReader(message))
|
||||
if err != nil {
|
||||
fmt.Printf("Ntfy: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if n.NtfyToken.Valid {
|
||||
req.Header.Set("Authorization", "Bearer "+n.NtfyToken.String)
|
||||
}
|
||||
// Use text/plain instead of application/json
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Ntfy: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendPushover(n models.Notification, message string) {
|
||||
form := url.Values{}
|
||||
form.Add("token", n.PushoverToken.String)
|
||||
form.Add("user", n.PushoverUser.String)
|
||||
form.Add("message", message)
|
||||
|
||||
req, err := http.NewRequest("POST", n.PushoverUrl.String, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
fmt.Printf("Pushover: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Pushover: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Pushover: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (ns *NotificationSender) sendEchobell(n models.Notification, message string) {
|
||||
jsonData := fmt.Sprintf(`{"message": "%s"}`, message)
|
||||
req, err := http.NewRequest("POST", n.EchobellURL.String, strings.NewReader(jsonData))
|
||||
if err != nil {
|
||||
fmt.Printf("Echobell: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Echobell: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Echobell: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
381
agent/internal/server/monitor.go
Normal file
381
agent/internal/server/monitor.go
Normal file
@ -0,0 +1,381 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/corecontrol/agent/internal/models"
|
||||
"github.com/corecontrol/agent/internal/notifications"
|
||||
)
|
||||
|
||||
// notificationState tracks the last known status for each server
|
||||
var notificationState = struct {
|
||||
sync.RWMutex
|
||||
lastStatus map[int]bool
|
||||
}{
|
||||
lastStatus: make(map[int]bool),
|
||||
}
|
||||
|
||||
// MonitorServers checks and updates the status of all servers
|
||||
func MonitorServers(db *sql.DB, client *http.Client, servers []models.Server, notifSender *notifications.NotificationSender) {
|
||||
var notificationTemplate string
|
||||
err := db.QueryRow("SELECT notification_text_server FROM settings LIMIT 1").Scan(¬ificationTemplate)
|
||||
if err != nil || notificationTemplate == "" {
|
||||
notificationTemplate = "The server !name is now !status!"
|
||||
}
|
||||
|
||||
for _, server := range servers {
|
||||
if !server.Monitoring || !server.MonitoringURL.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
logPrefix := fmt.Sprintf("[Server %s]", server.Name)
|
||||
fmt.Printf("%s Checking...\n", logPrefix)
|
||||
|
||||
baseURL := strings.TrimSuffix(server.MonitoringURL.String, "/")
|
||||
var cpuUsage, ramUsage, diskUsage, gpuUsage, temp float64
|
||||
var online = true
|
||||
var uptimeStr string
|
||||
|
||||
// Get CPU usage
|
||||
online, cpuUsage = fetchCPUUsage(client, baseURL, logPrefix)
|
||||
if !online {
|
||||
updateServerStatus(db, server.ID, false, 0, 0, 0, 0, 0, "")
|
||||
if shouldSendNotification(server.ID, online) {
|
||||
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
|
||||
}
|
||||
addServerHistoryEntry(db, server.ID, false, 0, 0, 0, 0, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get uptime if server is online
|
||||
uptimeStr = fetchUptime(client, baseURL, logPrefix)
|
||||
|
||||
// Get Memory usage
|
||||
memOnline, memUsage := fetchMemoryUsage(client, baseURL, logPrefix)
|
||||
if !memOnline {
|
||||
online = false
|
||||
updateServerStatus(db, server.ID, false, 0, 0, 0, 0, 0, "")
|
||||
if shouldSendNotification(server.ID, online) {
|
||||
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
|
||||
}
|
||||
addServerHistoryEntry(db, server.ID, false, 0, 0, 0, 0, 0)
|
||||
continue
|
||||
}
|
||||
ramUsage = memUsage
|
||||
|
||||
// Get Disk usage
|
||||
diskOnline, diskUsageVal := fetchDiskUsage(client, baseURL, logPrefix)
|
||||
if !diskOnline {
|
||||
online = false
|
||||
updateServerStatus(db, server.ID, false, 0, 0, 0, 0, 0, "")
|
||||
if shouldSendNotification(server.ID, online) {
|
||||
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
|
||||
}
|
||||
addServerHistoryEntry(db, server.ID, false, 0, 0, 0, 0, 0)
|
||||
continue
|
||||
}
|
||||
diskUsage = diskUsageVal
|
||||
|
||||
// Get GPU usage
|
||||
_, gpuUsageVal := fetchGPUUsage(client, baseURL, logPrefix)
|
||||
gpuUsage = gpuUsageVal
|
||||
|
||||
// Get Temperature
|
||||
_, tempVal := fetchTemperature(client, baseURL, logPrefix)
|
||||
temp = tempVal
|
||||
|
||||
// Check if status changed and send notification if needed
|
||||
if online != server.Online && shouldSendNotification(server.ID, online) {
|
||||
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
|
||||
}
|
||||
|
||||
// Update server status with metrics
|
||||
updateServerStatus(db, server.ID, online, cpuUsage, ramUsage, diskUsage, gpuUsage, temp, uptimeStr)
|
||||
|
||||
// Add entry to server history
|
||||
addServerHistoryEntry(db, server.ID, online, cpuUsage, ramUsage, diskUsage, gpuUsage, temp)
|
||||
|
||||
fmt.Printf("%s Updated - CPU: %.2f%%, RAM: %.2f%%, Disk: %.2f%%, GPU: %.2f%%, Temp: %.2f°C, Uptime: %s\n",
|
||||
logPrefix, cpuUsage, ramUsage, diskUsage, gpuUsage, temp, uptimeStr)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSendNotification checks if a notification should be sent based on status change
|
||||
func shouldSendNotification(serverID int, online bool) bool {
|
||||
notificationState.Lock()
|
||||
defer notificationState.Unlock()
|
||||
|
||||
lastStatus, exists := notificationState.lastStatus[serverID]
|
||||
|
||||
// If this is the first check or status has changed
|
||||
if !exists || lastStatus != online {
|
||||
notificationState.lastStatus[serverID] = online
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper function to fetch CPU usage
|
||||
func fetchCPUUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
|
||||
cpuResp, err := client.Get(fmt.Sprintf("%s/api/4/cpu", baseURL))
|
||||
if err != nil {
|
||||
fmt.Printf("%s CPU request failed: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
defer cpuResp.Body.Close()
|
||||
|
||||
if cpuResp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("%s Bad CPU status code: %d\n", logPrefix, cpuResp.StatusCode)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
var cpuData models.CPUResponse
|
||||
if err := json.NewDecoder(cpuResp.Body).Decode(&cpuData); err != nil {
|
||||
fmt.Printf("%s Failed to parse CPU JSON: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, cpuData.Total
|
||||
}
|
||||
|
||||
// Helper function to fetch memory usage
|
||||
func fetchMemoryUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
|
||||
memResp, err := client.Get(fmt.Sprintf("%s/api/4/mem", baseURL))
|
||||
if err != nil {
|
||||
fmt.Printf("%s Memory request failed: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
defer memResp.Body.Close()
|
||||
|
||||
if memResp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("%s Bad memory status code: %d\n", logPrefix, memResp.StatusCode)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
var memData models.MemoryResponse
|
||||
if err := json.NewDecoder(memResp.Body).Decode(&memData); err != nil {
|
||||
fmt.Printf("%s Failed to parse memory JSON: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, memData.Percent
|
||||
}
|
||||
|
||||
// Helper function to fetch disk usage
|
||||
func fetchDiskUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
|
||||
fsResp, err := client.Get(fmt.Sprintf("%s/api/4/fs", baseURL))
|
||||
if err != nil {
|
||||
fmt.Printf("%s Filesystem request failed: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
defer fsResp.Body.Close()
|
||||
|
||||
if fsResp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("%s Bad filesystem status code: %d\n", logPrefix, fsResp.StatusCode)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
var fsData models.FSResponse
|
||||
if err := json.NewDecoder(fsResp.Body).Decode(&fsData); err != nil {
|
||||
fmt.Printf("%s Failed to parse filesystem JSON: %v\n", logPrefix, err)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if len(fsData) > 0 {
|
||||
return true, fsData[0].Percent
|
||||
}
|
||||
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// Helper function to fetch uptime
|
||||
func fetchUptime(client *http.Client, baseURL, logPrefix string) string {
|
||||
uptimeResp, err := client.Get(fmt.Sprintf("%s/api/4/uptime", baseURL))
|
||||
if err != nil || uptimeResp.StatusCode != http.StatusOK {
|
||||
if err != nil {
|
||||
fmt.Printf("%s Uptime request failed: %v\n", logPrefix, err)
|
||||
} else {
|
||||
fmt.Printf("%s Bad uptime status code: %d\n", logPrefix, uptimeResp.StatusCode)
|
||||
uptimeResp.Body.Close()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
defer uptimeResp.Body.Close()
|
||||
|
||||
// Read the response body as a string first
|
||||
uptimeBytes, err := io.ReadAll(uptimeResp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("%s Failed to read uptime response: %v\n", logPrefix, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
uptimeStr := strings.Trim(string(uptimeBytes), "\"")
|
||||
|
||||
// Try to parse as JSON object first, then fallback to direct string if that fails
|
||||
var uptimeData models.UptimeResponse
|
||||
if jsonErr := json.Unmarshal(uptimeBytes, &uptimeData); jsonErr == nil && uptimeData.Value != "" {
|
||||
uptimeStr = formatUptime(uptimeData.Value)
|
||||
} else {
|
||||
// Use the string directly
|
||||
uptimeStr = formatUptime(uptimeStr)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Uptime: %s (formatted: %s)\n", logPrefix, string(uptimeBytes), uptimeStr)
|
||||
return uptimeStr
|
||||
}
|
||||
|
||||
// Helper function to fetch GPU usage
|
||||
func fetchGPUUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
|
||||
gpuResp, err := client.Get(fmt.Sprintf("%s/api/4/gpu", baseURL))
|
||||
if err != nil {
|
||||
fmt.Printf("%s GPU request failed: %v\n", logPrefix, err)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
defer gpuResp.Body.Close()
|
||||
|
||||
if gpuResp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("%s Bad GPU status code: %d\n", logPrefix, gpuResp.StatusCode)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
|
||||
var gpuData models.GPUResponse
|
||||
if err := json.NewDecoder(gpuResp.Body).Decode(&gpuData); err != nil {
|
||||
fmt.Printf("%s Failed to parse GPU JSON: %v\n", logPrefix, err)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
|
||||
return true, gpuData.Proc
|
||||
}
|
||||
|
||||
// Helper function to fetch temperature
|
||||
func fetchTemperature(client *http.Client, baseURL, logPrefix string) (bool, float64) {
|
||||
tempResp, err := client.Get(fmt.Sprintf("%s/api/4/sensors/label/value/Composite", baseURL))
|
||||
if err != nil {
|
||||
fmt.Printf("%s Temperature request failed: %v\n", logPrefix, err)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
defer tempResp.Body.Close()
|
||||
|
||||
if tempResp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("%s Bad temperature status code: %d\n", logPrefix, tempResp.StatusCode)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
|
||||
var tempData models.TemperatureResponse
|
||||
if err := json.NewDecoder(tempResp.Body).Decode(&tempData); err != nil {
|
||||
fmt.Printf("%s Failed to parse temperature JSON: %v\n", logPrefix, err)
|
||||
return true, 0 // Return true to indicate server is still online
|
||||
}
|
||||
|
||||
if len(tempData.Composite) > 0 {
|
||||
return true, tempData.Composite[0].Value
|
||||
}
|
||||
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// Helper function to send notification about status change
|
||||
func sendStatusChangeNotification(server models.Server, online bool, template string, notifSender *notifications.NotificationSender) {
|
||||
status := "offline"
|
||||
if online {
|
||||
status = "online"
|
||||
}
|
||||
|
||||
message := strings.ReplaceAll(template, "!name", server.Name)
|
||||
message = strings.ReplaceAll(message, "!status", status)
|
||||
|
||||
notifSender.SendNotifications(message)
|
||||
}
|
||||
|
||||
// Helper function to update server status
|
||||
func updateServerStatus(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage, gpuUsage, temp float64, uptime string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE server SET online = $1, "cpuUsage" = $2::float8, "ramUsage" = $3::float8, "diskUsage" = $4::float8, "gpuUsage" = $5::float8, "temp" = $6::float8, "uptime" = $7
|
||||
WHERE id = $8`,
|
||||
online, cpuUsage, ramUsage, diskUsage, gpuUsage, temp, uptime, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to update server status (ID: %d): %v\n", serverID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to add server history entry
|
||||
func addServerHistoryEntry(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage, gpuUsage, temp float64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO server_history(
|
||||
"serverId", online, "cpuUsage", "ramUsage", "diskUsage", "gpuUsage", "temp", "createdAt"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, now())`,
|
||||
serverID, online, fmt.Sprintf("%.2f", cpuUsage), fmt.Sprintf("%.2f", ramUsage),
|
||||
fmt.Sprintf("%.2f", diskUsage), fmt.Sprintf("%.2f", gpuUsage), fmt.Sprintf("%.2f", temp),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to insert server history (ID: %d): %v\n", serverID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatUptime formats the uptime string to a standard format
|
||||
func formatUptime(uptimeStr string) string {
|
||||
// Example input: "3 days, 3:52:36"
|
||||
// Target output: "28.6 13:52"
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Parse the uptime components
|
||||
parts := strings.Split(uptimeStr, ", ")
|
||||
|
||||
var days int
|
||||
var timeStr string
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Has days part and time part
|
||||
_, err := fmt.Sscanf(parts[0], "%d days", &days)
|
||||
if err != nil {
|
||||
// Try singular "day"
|
||||
_, err = fmt.Sscanf(parts[0], "%d day", &days)
|
||||
if err != nil {
|
||||
return uptimeStr // Return original if parsing fails
|
||||
}
|
||||
}
|
||||
timeStr = parts[1]
|
||||
} else if len(parts) == 1 {
|
||||
// Only has time part (less than a day)
|
||||
days = 0
|
||||
timeStr = parts[0]
|
||||
} else {
|
||||
return uptimeStr // Return original if format is unexpected
|
||||
}
|
||||
|
||||
// Parse the time component (hours:minutes:seconds)
|
||||
var hours, minutes, seconds int
|
||||
_, err := fmt.Sscanf(timeStr, "%d:%d:%d", &hours, &minutes, &seconds)
|
||||
if err != nil {
|
||||
return uptimeStr // Return original if parsing fails
|
||||
}
|
||||
|
||||
// Calculate the total duration
|
||||
duration := time.Duration(days)*24*time.Hour +
|
||||
time.Duration(hours)*time.Hour +
|
||||
time.Duration(minutes)*time.Minute +
|
||||
time.Duration(seconds)*time.Second
|
||||
|
||||
// Calculate the start time by subtracting the duration from now
|
||||
startTime := now.Add(-duration)
|
||||
|
||||
// Format the result in the required format (day.month hour:minute)
|
||||
return startTime.Format("2.1 15:04")
|
||||
}
|
||||
35
app/api/applications/add/route.ts
Normal file
35
app/api/applications/add/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
serverId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
publicURL: string;
|
||||
localURL: string;
|
||||
uptimecheckUrl: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { serverId, name, description, icon, publicURL, localURL, uptimecheckUrl } = body;
|
||||
|
||||
const application = await prisma.application.create({
|
||||
data: {
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
publicURL,
|
||||
localURL,
|
||||
uptimecheckUrl
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", application });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/api/applications/delete/route.ts
Normal file
25
app/api/applications/delete/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.application.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
await prisma.uptime_history.deleteMany({
|
||||
where: { applicationId: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
app/api/applications/edit/route.ts
Normal file
42
app/api/applications/edit/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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;
|
||||
uptimecheckUrl: string;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: EditRequest = await request.json();
|
||||
const { id, name, description, serverId, icon, publicURL, localURL, uptimecheckUrl } = 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,
|
||||
uptimecheckUrl
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Application updated", application: updatedApplication });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
app/api/applications/get/route.ts
Normal file
50
app/api/applications/get/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface PostRequest {
|
||||
page?: number;
|
||||
ITEMS_PER_PAGE?: number;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: PostRequest = await request.json();
|
||||
const page = Math.max(1, body.page || 1);
|
||||
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 10;
|
||||
|
||||
const [applications, totalCount, servers_all] = await Promise.all([
|
||||
prisma.application.findMany({
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
take: ITEMS_PER_PAGE,
|
||||
orderBy: { name: "asc" }
|
||||
}),
|
||||
prisma.application.count(),
|
||||
prisma.server.findMany()
|
||||
]);
|
||||
|
||||
const serverIds = applications
|
||||
.map((app: { serverId: number | null }) => app.serverId)
|
||||
.filter((id:any): id is number => id !== null);
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { id: { in: serverIds } }
|
||||
});
|
||||
|
||||
const applicationsWithServers = applications.map((app: any) => ({
|
||||
...app,
|
||||
server: servers.find((s: any) => s.id === app.serverId)?.name || "No server"
|
||||
}));
|
||||
|
||||
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return NextResponse.json({
|
||||
applications: applicationsWithServers,
|
||||
servers: servers_all,
|
||||
maxPage,
|
||||
totalItems: totalCount
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
app/api/applications/search/route.ts
Normal file
48
app/api/applications/search/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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 searchedApps = searchResults.map(({ item }) => item);
|
||||
|
||||
// Get server IDs from the search results
|
||||
const serverIds = searchedApps
|
||||
.map(app => app.serverId)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
// Fetch server data for these applications
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { id: { in: serverIds } }
|
||||
});
|
||||
|
||||
// Add server name to each application
|
||||
const results = searchedApps.map(app => ({
|
||||
...app,
|
||||
server: servers.find(s => s.id === app.serverId)?.name || "No server"
|
||||
}));
|
||||
|
||||
return NextResponse.json({ results });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
175
app/api/applications/uptime/route.ts
Normal file
175
app/api/applications/uptime/route.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface RequestBody {
|
||||
timespan?: number;
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
|
||||
const getTimeRange = (timespan: number) => {
|
||||
const now = new Date();
|
||||
switch (timespan) {
|
||||
case 1:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
interval: 'minute'
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
interval: 'hour'
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
interval: 'day'
|
||||
};
|
||||
case 4:
|
||||
return {
|
||||
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
interval: 'day'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
interval: 'minute'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const generateIntervals = (timespan: number) => {
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
|
||||
switch (timespan) {
|
||||
case 1: // 1 hour - 60 one-minute intervals
|
||||
return Array.from({ length: 60 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setMinutes(d.getMinutes() - i);
|
||||
d.setSeconds(0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 2: // 1 day - 24 one-hour intervals
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setHours(d.getHours() - i);
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 3: // 7 days
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
case 4: // 30 days
|
||||
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: // 1 hour - minute intervals
|
||||
d.setSeconds(0, 0);
|
||||
return d.toISOString();
|
||||
case 2: // 1 day - hour intervals
|
||||
d.setMinutes(0, 0, 0);
|
||||
return d.toISOString();
|
||||
case 3: // 7 days - day intervals
|
||||
case 4: // 30 days - day intervals
|
||||
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, itemsPerPage = 5 }: RequestBody = await request.json();
|
||||
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 'bcryptjs';
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
65
app/api/auth/login/route.ts
Normal file
65
app/api/auth/login/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: LoginRequest = await request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
let accountId: string = '';
|
||||
// Check if there are any entries in user
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
if(username=== "admin@example.com" && password === "admin") {
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
// Create the first user with hashed password
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the account id
|
||||
accountId = user.id;
|
||||
} else {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
} else {
|
||||
// Get the user by username
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: username },
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
// Check if the password is correct
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
|
||||
}
|
||||
// Get the account id
|
||||
accountId = user.id;
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
const token = jwt.sign({ account_secret: accountId }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
||||
|
||||
return NextResponse.json({ token });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
app/api/auth/validate/route.ts
Normal file
43
app/api/auth/validate/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface ValidateRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: ValidateRequest = await request.json();
|
||||
const { token } = body;
|
||||
|
||||
// Ensure JWT_SECRET is defined
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
// Get the account id
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {},
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
|
||||
|
||||
if(!decoded.account_secret) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
if(decoded.account_secret !== user.id) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({ message: 'Valid token' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
app/api/dashboard/get/route.ts
Normal file
35
app/api/dashboard/get/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const serverCountNoVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: 0
|
||||
}
|
||||
});
|
||||
|
||||
const serverCountOnlyVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: {
|
||||
not: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const applicationCount = await prisma.application.count();
|
||||
|
||||
const onlineApplicationsCount = await prisma.application.count({
|
||||
where: { online: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
serverCountNoVMs,
|
||||
serverCountOnlyVMs,
|
||||
applicationCount,
|
||||
onlineApplicationsCount
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
409
app/api/flowchart/route.ts
Normal file
409
app/api/flowchart/route.ts
Normal file
@ -0,0 +1,409 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
label: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
position: { x: number; y: number };
|
||||
style: React.CSSProperties;
|
||||
draggable?: boolean;
|
||||
selectable?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
style: {
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
host: boolean;
|
||||
hostServer: number | null;
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: number;
|
||||
name: string;
|
||||
localURL: string;
|
||||
serverId: number;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 60;
|
||||
const APP_NODE_WIDTH = 160;
|
||||
const APP_NODE_HEIGHT = 40;
|
||||
const HORIZONTAL_SPACING = 700;
|
||||
const VERTICAL_SPACING = 80;
|
||||
const START_Y = 120;
|
||||
const ROOT_NODE_WIDTH = 300;
|
||||
const CONTAINER_PADDING = 40;
|
||||
const COLUMN_SPACING = 220;
|
||||
const VM_APP_SPACING = 220;
|
||||
const MIN_VM_SPACING = 10;
|
||||
const APP_ROW_SPACING = 15;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [servers, applications] = await Promise.all([
|
||||
prisma.server.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
}) as Promise<Server[]>,
|
||||
prisma.application.findMany({
|
||||
orderBy: { serverId: "asc" },
|
||||
}) as Promise<Application[]>,
|
||||
]);
|
||||
|
||||
// Level 2: Physical Servers
|
||||
const serverNodes: Node[] = servers
|
||||
.filter(server => !server.hostServer)
|
||||
.map((server, index, filteredServers) => {
|
||||
const xPos =
|
||||
index * HORIZONTAL_SPACING -
|
||||
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
|
||||
|
||||
return {
|
||||
id: `server-${server.id}`,
|
||||
type: "server",
|
||||
data: {
|
||||
label: `${server.name}\n${server.ip}`,
|
||||
...server,
|
||||
},
|
||||
position: { x: xPos, y: START_Y },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "4px",
|
||||
padding: "8px",
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "0.9rem",
|
||||
lineHeight: "1.2",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Level 3: Services and VMs
|
||||
const serviceNodes: Node[] = [];
|
||||
const vmNodes: Node[] = [];
|
||||
|
||||
servers.forEach((server) => {
|
||||
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
|
||||
if (serverNode) {
|
||||
const serverX = serverNode.position.x;
|
||||
|
||||
// Services (left column)
|
||||
applications
|
||||
.filter(app => app.serverId === server.id)
|
||||
.forEach((app, appIndex) => {
|
||||
serviceNodes.push({
|
||||
id: `service-${app.id}`,
|
||||
type: "service",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: serverX - COLUMN_SPACING,
|
||||
y: START_Y + NODE_HEIGHT + VERTICAL_SPACING + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f0f9ff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #60a5fa",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// VMs (middle column) mit dynamischem Abstand
|
||||
const hostVMs = servers.filter(vm => vm.hostServer === server.id);
|
||||
let currentY = START_Y + NODE_HEIGHT + VERTICAL_SPACING;
|
||||
|
||||
hostVMs.forEach(vm => {
|
||||
const appCount = applications.filter(app => app.serverId === vm.id).length;
|
||||
|
||||
vmNodes.push({
|
||||
id: `vm-${vm.id}`,
|
||||
type: "vm",
|
||||
data: {
|
||||
label: `${vm.name}\n${vm.ip}`,
|
||||
...vm,
|
||||
},
|
||||
position: {
|
||||
x: serverX,
|
||||
y: currentY,
|
||||
},
|
||||
style: {
|
||||
background: "#fef2f2",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #fecaca",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamischer Abstand basierend auf Anzahl Apps
|
||||
const requiredSpace = appCount > 0
|
||||
? (appCount * (APP_NODE_HEIGHT + APP_ROW_SPACING))
|
||||
: 0;
|
||||
|
||||
currentY += Math.max(
|
||||
requiredSpace + MIN_VM_SPACING,
|
||||
MIN_VM_SPACING + APP_NODE_HEIGHT
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Level 4: VM Applications (right column)
|
||||
const vmAppNodes: Node[] = [];
|
||||
vmNodes.forEach((vm) => {
|
||||
const vmX = vm.position.x;
|
||||
applications
|
||||
.filter(app => app.serverId === vm.data.id)
|
||||
.forEach((app, appIndex) => {
|
||||
vmAppNodes.push({
|
||||
id: `vm-app-${app.id}`,
|
||||
type: "application",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: vmX + VM_APP_SPACING,
|
||||
y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f5f5f5",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate dimensions for root node positioning
|
||||
const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
tempNodes.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 centerX = (minX + maxX) / 2;
|
||||
const rootX = centerX - ROOT_NODE_WIDTH / 2;
|
||||
|
||||
// Level 1: Root Node (centered at top)
|
||||
const rootNode: Node = {
|
||||
id: "root",
|
||||
type: "infrastructure",
|
||||
data: { label: "My Infrastructure" },
|
||||
position: { x: rootX, y: 0 },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
width: ROOT_NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "1.2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
};
|
||||
|
||||
// Update dimensions with root node
|
||||
const allNodes = [rootNode, ...tempNodes];
|
||||
let newMinX = Math.min(minX, rootNode.position.x);
|
||||
let newMaxX = Math.max(maxX, rootNode.position.x + ROOT_NODE_WIDTH);
|
||||
let newMinY = Math.min(minY, rootNode.position.y);
|
||||
let newMaxY = Math.max(maxY, rootNode.position.y + NODE_HEIGHT);
|
||||
|
||||
// Container Node
|
||||
const containerNode: Node = {
|
||||
id: 'container',
|
||||
type: 'container',
|
||||
data: { label: '' },
|
||||
position: {
|
||||
x: newMinX - CONTAINER_PADDING,
|
||||
y: newMinY - CONTAINER_PADDING
|
||||
},
|
||||
style: {
|
||||
width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
|
||||
height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
|
||||
background: 'transparent',
|
||||
border: '2px dashed #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
zIndex: 0,
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
zIndex: -1,
|
||||
};
|
||||
|
||||
// Connections with hierarchical chaining
|
||||
const connections: Edge[] = [];
|
||||
|
||||
// Root to Servers
|
||||
serverNodes.forEach((server) => {
|
||||
connections.push({
|
||||
id: `conn-root-${server.id}`,
|
||||
source: "root",
|
||||
target: server.id,
|
||||
type: "straight",
|
||||
style: {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Services chaining
|
||||
const servicesByServer = new Map<number, Node[]>();
|
||||
serviceNodes.forEach(service => {
|
||||
const serverId = service.data.serverId;
|
||||
if (!servicesByServer.has(serverId)) servicesByServer.set(serverId, []);
|
||||
servicesByServer.get(serverId)!.push(service);
|
||||
});
|
||||
servicesByServer.forEach((services, serverId) => {
|
||||
services.sort((a, b) => a.position.y - b.position.y);
|
||||
services.forEach((service, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}`,
|
||||
source: `server-${serverId}`,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevService = services[index - 1];
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}-${prevService.id}`,
|
||||
source: prevService.id,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VMs chaining
|
||||
const vmsByHost = new Map<number, Node[]>();
|
||||
vmNodes.forEach(vm => {
|
||||
const hostId = vm.data.hostServer;
|
||||
if (!vmsByHost.has(hostId)) vmsByHost.set(hostId, []);
|
||||
vmsByHost.get(hostId)!.push(vm);
|
||||
});
|
||||
vmsByHost.forEach((vms, hostId) => {
|
||||
vms.sort((a, b) => a.position.y - b.position.y);
|
||||
vms.forEach((vm, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}`,
|
||||
source: `server-${hostId}`,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevVm = vms[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}-${prevVm.id}`,
|
||||
source: prevVm.id,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VM Applications chaining
|
||||
const appsByVM = new Map<number, Node[]>();
|
||||
vmAppNodes.forEach(app => {
|
||||
const vmId = app.data.serverId;
|
||||
if (!appsByVM.has(vmId)) appsByVM.set(vmId, []);
|
||||
appsByVM.get(vmId)!.push(app);
|
||||
});
|
||||
appsByVM.forEach((apps, vmId) => {
|
||||
apps.sort((a, b) => a.position.y - b.position.y);
|
||||
apps.forEach((app, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}`,
|
||||
source: `vm-${vmId}`,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevApp = apps[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}-${prevApp.id}`,
|
||||
source: prevApp.id,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
nodes: [containerNode, ...allNodes],
|
||||
edges: connections,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Error fetching flowchart: ${errorMessage}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/api/notifications/add/route.ts
Normal file
61
app/api/notifications/add/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
type: string;
|
||||
name: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTo?: string;
|
||||
telegramToken?: string;
|
||||
telegramChatId?: string;
|
||||
discordWebhook?: string;
|
||||
gotifyUrl?: string;
|
||||
gotifyToken?: string;
|
||||
ntfyUrl?: string;
|
||||
ntfyToken?: string;
|
||||
pushoverUrl?: string;
|
||||
pushoverToken?: string;
|
||||
pushoverUser?: string;
|
||||
echobellURL?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { type, name, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken, pushoverUrl, pushoverToken, pushoverUser, echobellURL } = body;
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
type: type,
|
||||
name: name,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpUser: smtpUsername,
|
||||
smtpPass: smtpPassword,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpTo: smtpTo,
|
||||
telegramChatId: telegramChatId,
|
||||
telegramToken: telegramToken,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
pushoverUrl: pushoverUrl,
|
||||
pushoverToken: pushoverToken,
|
||||
pushoverUser: pushoverUser,
|
||||
echobellURL: echobellURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", notification });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/notifications/delete/route.ts
Normal file
21
app/api/notifications/delete/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.notification.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
app/api/notifications/get/route.ts
Normal file
16
app/api/notifications/get/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
const notifications = await prisma.notification.findMany();
|
||||
|
||||
return NextResponse.json({
|
||||
notifications
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/notifications/test/route.ts
Normal file
23
app/api/notifications/test/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
notificationId: number;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { notificationId } = body;
|
||||
|
||||
const notification = await prisma.test_notification.create({
|
||||
data: {
|
||||
notificationId: notificationId,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", notification });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
app/api/servers/add/route.ts
Normal file
48
app/api/servers/add/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
ram: string;
|
||||
disk: string;
|
||||
monitoring: boolean;
|
||||
monitoringURL: string;
|
||||
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
|
||||
|
||||
const server = await prisma.server.create({
|
||||
data: {
|
||||
host,
|
||||
hostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
disk,
|
||||
monitoring,
|
||||
monitoringURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", server });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
app/api/servers/delete/route.ts
Normal file
35
app/api/servers/delete/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const id = Number(body.id);
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if there are any applications associated with the server
|
||||
const applications = await prisma.application.findMany({
|
||||
where: { serverId: id }
|
||||
});
|
||||
if (applications.length > 0) {
|
||||
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete all server history records for this server
|
||||
await prisma.server_history.deleteMany({
|
||||
where: { serverId: id }
|
||||
});
|
||||
|
||||
// Delete the server
|
||||
await prisma.server.delete({
|
||||
where: { id: id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
61
app/api/servers/edit/route.ts
Normal file
61
app/api/servers/edit/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface EditRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
ram: string;
|
||||
disk: string;
|
||||
monitoring: boolean;
|
||||
monitoringURL: string;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: EditRequest = await request.json();
|
||||
const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
|
||||
|
||||
const existingServer = await prisma.server.findUnique({ where: { id } });
|
||||
if (!existingServer) {
|
||||
return NextResponse.json({ error: "Server not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let newHostServer = hostServer;
|
||||
if (hostServer === null) {
|
||||
newHostServer = 0;
|
||||
} else {
|
||||
newHostServer = hostServer;
|
||||
}
|
||||
|
||||
const updatedServer = await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
host,
|
||||
hostServer: newHostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
disk,
|
||||
monitoring,
|
||||
monitoringURL
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Server updated", server: updatedServer });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
270
app/api/servers/get/route.ts
Normal file
270
app/api/servers/get/route.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
interface GetRequest {
|
||||
page?: number;
|
||||
ITEMS_PER_PAGE?: number;
|
||||
timeRange?: '1h' | '1d' | '7d' | '30d';
|
||||
serverId?: number;
|
||||
}
|
||||
|
||||
const getTimeRange = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case '1d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 15 // 15 minute intervals
|
||||
};
|
||||
case '7d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 60 // 1 hour intervals
|
||||
};
|
||||
case '30d':
|
||||
return {
|
||||
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 240 // 4 hour intervals
|
||||
};
|
||||
case '1h':
|
||||
default:
|
||||
return {
|
||||
start: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
end: now,
|
||||
intervalMinutes: 1 // 1 minute intervals
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getIntervals = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
|
||||
const { start, end, intervalMinutes } = getTimeRange(timeRange);
|
||||
|
||||
let intervalCount: number;
|
||||
switch (timeRange) {
|
||||
case '1d':
|
||||
intervalCount = 96; // 24 hours * 4 (15-minute intervals)
|
||||
break;
|
||||
case '7d':
|
||||
intervalCount = 168; // 7 days * 24 hours
|
||||
break;
|
||||
case '30d':
|
||||
intervalCount = 180; // 30 days * 6 (4-hour intervals)
|
||||
break;
|
||||
case '1h':
|
||||
default:
|
||||
intervalCount = 60;
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate the total time span in minutes
|
||||
const totalMinutes = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
|
||||
|
||||
// Create equally spaced intervals
|
||||
return Array.from({ length: intervalCount }, (_, i) => {
|
||||
const minutesFromEnd = Math.floor(i * (totalMinutes / (intervalCount - 1)));
|
||||
const d = new Date(end.getTime() - minutesFromEnd * 60 * 1000);
|
||||
return d;
|
||||
}).reverse(); // Return in chronological order
|
||||
};
|
||||
|
||||
const parseUsageValue = (value: string | null): number => {
|
||||
if (!value) return 0;
|
||||
return Math.round(parseFloat(value.replace('%', '')) * 100) / 100;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: GetRequest = await request.json();
|
||||
const page = Math.max(1, body.page || 1);
|
||||
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
|
||||
const timeRange = body.timeRange || '1h';
|
||||
const serverId = body.serverId;
|
||||
|
||||
// If serverId is provided, only fetch that specific server
|
||||
const hostsQuery = serverId
|
||||
? { id: serverId }
|
||||
: { hostServer: 0 };
|
||||
|
||||
let hosts;
|
||||
if (!serverId) {
|
||||
hosts = await prisma.server.findMany({
|
||||
where: hostsQuery,
|
||||
orderBy: { name: 'asc' as Prisma.SortOrder },
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
take: ITEMS_PER_PAGE,
|
||||
});
|
||||
} else {
|
||||
hosts = await prisma.server.findMany({
|
||||
where: hostsQuery,
|
||||
orderBy: { name: 'asc' as Prisma.SortOrder },
|
||||
});
|
||||
}
|
||||
|
||||
const { start } = getTimeRange(timeRange);
|
||||
const intervals = getIntervals(timeRange);
|
||||
|
||||
const hostsWithVms = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const vms = await prisma.server.findMany({
|
||||
where: { hostServer: host.id },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
// Get server history for the host
|
||||
const serverHistory = await prisma.server_history.findMany({
|
||||
where: {
|
||||
serverId: host.id,
|
||||
createdAt: {
|
||||
gte: start
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
// Process history data into intervals
|
||||
const historyMap = new Map<string, {
|
||||
cpu: number[],
|
||||
ram: number[],
|
||||
disk: number[],
|
||||
gpu: number[],
|
||||
temp: number[],
|
||||
online: boolean[]
|
||||
}>();
|
||||
|
||||
// Initialize intervals
|
||||
intervals.forEach(date => {
|
||||
const key = date.toISOString();
|
||||
historyMap.set(key, {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
disk: [],
|
||||
gpu: [],
|
||||
temp: [],
|
||||
online: []
|
||||
});
|
||||
});
|
||||
|
||||
// Group data by interval
|
||||
serverHistory.forEach(record => {
|
||||
const recordDate = new Date(record.createdAt);
|
||||
let nearestInterval: Date = intervals[0];
|
||||
let minDiff = Infinity;
|
||||
|
||||
// Find the nearest interval for this record
|
||||
intervals.forEach(intervalDate => {
|
||||
const diff = Math.abs(recordDate.getTime() - intervalDate.getTime());
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nearestInterval = intervalDate;
|
||||
}
|
||||
});
|
||||
|
||||
const key = nearestInterval.toISOString();
|
||||
const interval = historyMap.get(key);
|
||||
if (interval) {
|
||||
interval.cpu.push(parseUsageValue(record.cpuUsage));
|
||||
interval.ram.push(parseUsageValue(record.ramUsage));
|
||||
interval.disk.push(parseUsageValue(record.diskUsage));
|
||||
interval.gpu.push(parseUsageValue(record.gpuUsage));
|
||||
interval.temp.push(parseUsageValue(record.temp));
|
||||
interval.online.push(record.online);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate averages for each interval
|
||||
const historyData = intervals.map(date => {
|
||||
const key = date.toISOString();
|
||||
const data = historyMap.get(key) || {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
disk: [],
|
||||
gpu: [],
|
||||
temp: [],
|
||||
online: []
|
||||
};
|
||||
|
||||
const average = (arr: number[]) =>
|
||||
arr.length ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : null;
|
||||
|
||||
return {
|
||||
timestamp: key,
|
||||
cpu: average(data.cpu),
|
||||
ram: average(data.ram),
|
||||
disk: average(data.disk),
|
||||
gpu: average(data.gpu),
|
||||
temp: average(data.temp),
|
||||
online: data.online.length ?
|
||||
data.online.filter(Boolean).length / data.online.length >= 0.5
|
||||
: null
|
||||
};
|
||||
});
|
||||
|
||||
// Add isVM flag to VMs
|
||||
const vmsWithFlag = vms.map(vm => ({
|
||||
...vm,
|
||||
isVM: true,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array for VMs
|
||||
}));
|
||||
|
||||
return {
|
||||
...host,
|
||||
isVM: false,
|
||||
hostedVMs: vmsWithFlag,
|
||||
history: {
|
||||
labels: intervals.map(d => d.toISOString()),
|
||||
datasets: {
|
||||
cpu: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.cpu || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
ram: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.ram || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
disk: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.disk || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
gpu: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.gpu || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
temp: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.temp || [];
|
||||
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
|
||||
}),
|
||||
online: intervals.map(d => {
|
||||
const data = historyMap.get(d.toISOString())?.online || [];
|
||||
return data.length ? data.filter(Boolean).length / data.length >= 0.5 : null;
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Only calculate maxPage when not requesting a specific server
|
||||
let maxPage = 1;
|
||||
let totalHosts = 0;
|
||||
if (!serverId) {
|
||||
totalHosts = await prisma.server.count({
|
||||
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
|
||||
});
|
||||
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
servers: hostsWithVms,
|
||||
maxPage,
|
||||
totalItems: totalHosts
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/servers/hosts/route.ts
Normal file
21
app/api/servers/hosts/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { host: true },
|
||||
});
|
||||
|
||||
// Add required properties to ensure consistency
|
||||
const serversWithProps = servers.map(server => ({
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array
|
||||
}));
|
||||
|
||||
return NextResponse.json({ servers: serversWithProps });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
app/api/servers/monitoring/route.ts
Normal file
44
app/api/servers/monitoring/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
online: true,
|
||||
cpuUsage: true,
|
||||
ramUsage: true,
|
||||
diskUsage: true,
|
||||
gpuUsage: true,
|
||||
temp: true,
|
||||
uptime: true
|
||||
}
|
||||
});
|
||||
|
||||
const monitoringData = servers.map((server: {
|
||||
id: number;
|
||||
online: boolean;
|
||||
cpuUsage: string | null;
|
||||
ramUsage: string | null;
|
||||
diskUsage: string | null;
|
||||
gpuUsage: string | null;
|
||||
temp: string | null;
|
||||
uptime: string | null;
|
||||
}) => ({
|
||||
id: server.id,
|
||||
online: server.online,
|
||||
cpuUsage: server.cpuUsage ? parseFloat(server.cpuUsage) : 0,
|
||||
ramUsage: server.ramUsage ? parseFloat(server.ramUsage) : 0,
|
||||
diskUsage: server.diskUsage ? parseFloat(server.diskUsage) : 0,
|
||||
gpuUsage: server.gpuUsage ? parseFloat(server.gpuUsage) : 0,
|
||||
temp: server.temp ? parseFloat(server.temp) : 0,
|
||||
uptime: server.uptime || ""
|
||||
}));
|
||||
|
||||
return NextResponse.json(monitoringData)
|
||||
} catch (error) {
|
||||
return new NextResponse("Internal Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
68
app/api/servers/search/route.ts
Normal file
68
app/api/servers/search/route.ts
Normal file
@ -0,0 +1,68 @@
|
||||
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;
|
||||
|
||||
// Fetch all servers
|
||||
const servers = await prisma.server.findMany({});
|
||||
|
||||
// Create a map of host servers with their hosted VMs
|
||||
const serverMap = new Map();
|
||||
servers.forEach(server => {
|
||||
if (server.host) {
|
||||
serverMap.set(server.id, {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add VMs to their host servers and mark them as VMs
|
||||
const serversWithType = servers.map(server => {
|
||||
// If not a host and has a hostServer, it's a VM
|
||||
if (!server.host && server.hostServer) {
|
||||
const hostServer = serverMap.get(server.hostServer);
|
||||
if (hostServer) {
|
||||
hostServer.hostedVMs.push({
|
||||
...server,
|
||||
isVM: true
|
||||
});
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: serverMap.get(server.id)?.hostedVMs || []
|
||||
};
|
||||
});
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(serversWithType, 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 });
|
||||
}
|
||||
}
|
||||
25
app/api/settings/get_notification_text/route.ts
Normal file
25
app/api/settings/get_notification_text/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check if there are any settings entries
|
||||
const existingSettings = await prisma.settings.findFirst();
|
||||
if (!existingSettings) {
|
||||
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
|
||||
}
|
||||
|
||||
// If settings entry exists, fetch it
|
||||
const settings = await prisma.settings.findFirst({
|
||||
where: { id: existingSettings.id },
|
||||
});
|
||||
if (!settings) {
|
||||
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
|
||||
}
|
||||
// Return the settings entry
|
||||
return NextResponse.json({ "notification_text_application": settings.notification_text_application, "notification_text_server": settings.notification_text_server });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
36
app/api/settings/notification_text/route.ts
Normal file
36
app/api/settings/notification_text/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
text_application: string;
|
||||
text_server: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { text_application, text_server } = body;
|
||||
|
||||
// Check if there is already a settings entry
|
||||
const existingSettings = await prisma.settings.findFirst();
|
||||
if (existingSettings) {
|
||||
// Update the existing settings entry
|
||||
const updatedSettings = await prisma.settings.update({
|
||||
where: { id: existingSettings.id },
|
||||
data: { notification_text_application: text_application, notification_text_server: text_server },
|
||||
});
|
||||
return NextResponse.json({ message: "Success", updatedSettings });
|
||||
}
|
||||
// If no settings entry exists, create a new one
|
||||
const settings = await prisma.settings.create({
|
||||
data: {
|
||||
notification_text_application: text_application,
|
||||
notification_text_server: text_server,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", settings });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
231
app/dashboard/Dashboard.tsx
Normal file
231
app/dashboard/Dashboard.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import axios from "axios"
|
||||
import Link from "next/link"
|
||||
import { Activity, Layers, Network, Server } from "lucide-react"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface StatsResponse {
|
||||
serverCountNoVMs: number
|
||||
serverCountOnlyVMs: number
|
||||
applicationCount: number
|
||||
onlineApplicationsCount: number
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('Dashboard')
|
||||
const [serverCountNoVMs, setServerCountNoVMs] = useState<number>(0)
|
||||
const [serverCountOnlyVMs, setServerCountOnlyVMs] = useState<number>(0)
|
||||
const [applicationCount, setApplicationCount] = useState<number>(0)
|
||||
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
|
||||
|
||||
const getStats = async () => {
|
||||
try {
|
||||
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
|
||||
setServerCountNoVMs(response.data.serverCountNoVMs)
|
||||
setServerCountOnlyVMs(response.data.serverCountOnlyVMs)
|
||||
setApplicationCount(response.data.applicationCount)
|
||||
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
|
||||
} catch (error: any) {
|
||||
console.log("Axios error:", error.response?.data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getStats()
|
||||
}, [])
|
||||
|
||||
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>{t('Title')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-6">{t('Title')}</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
|
||||
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-lg transition-all hover:shadow-xl hover:border-t-rose-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Servers.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Servers.Description')}</CardDescription>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Physical Servers */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-rose-100 p-2 rounded-full">
|
||||
<Server className="h-6 w-6 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountNoVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">{t('Servers.PhysicalServers')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Virtual Machines */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-violet-100 p-2 rounded-full">
|
||||
<Network className="h-6 w-6 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountOnlyVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">{t('Servers.VirtualServers')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/servers" className="flex items-center justify-between">
|
||||
<span>{t('Servers.ManageServers')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-lg transition-all hover:shadow-xl hover:border-t-amber-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Applications.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Applications.Description')}</CardDescription>
|
||||
</div>
|
||||
<Layers className="h-8 w-8 text-amber-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="text-4xl font-bold">{applicationCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Applications.OnlineApplications')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/applications" className="flex items-center justify-between">
|
||||
<span>{t('Applications.ViewAllApplications')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-lg transition-all hover:shadow-xl hover:border-t-emerald-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Uptime.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Uptime.Description')}</CardDescription>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-emerald-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-4xl font-bold flex items-center justify-between">
|
||||
<span>
|
||||
{onlineApplicationsCount}/{applicationCount}
|
||||
</span>
|
||||
<div className="flex items-center bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-lg font-semibold">
|
||||
{applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-3">
|
||||
<div
|
||||
className="bg-emerald-500 h-2.5 rounded-full"
|
||||
style={{
|
||||
width: `${applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Uptime.OnlineApplications')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/uptime" className="flex items-center justify-between">
|
||||
<span>{t('Uptime.ViewUptimeMetrics')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-lg transition-all hover:shadow-xl hover:border-t-sky-600">
|
||||
<CardHeader className="py-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">{t('Network.Title')}</CardTitle>
|
||||
<CardDescription className="mt-1">{t('Network.Description')}</CardDescription>
|
||||
</div>
|
||||
<Network className="h-8 w-8 text-sky-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-1 pb-2 min-h-[120px]">
|
||||
<div className="text-4xl font-bold">{serverCountNoVMs + serverCountOnlyVMs + applicationCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">{t('Network.ActiveConnections')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/10 py-2 px-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/network" className="flex items-center justify-between">
|
||||
<span>{t('Network.ViewNetworkDetails')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
1172
app/dashboard/applications/Applications.tsx
Normal file
1172
app/dashboard/applications/Applications.tsx
Normal file
File diff suppressed because it is too large
Load Diff
59
app/dashboard/applications/page.tsx
Normal file
59
app/dashboard/applications/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 Applications from "./Applications"
|
||||
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 ? <Applications /> : null;
|
||||
}
|
||||
101
app/dashboard/network/Networks.tsx
Normal file
101
app/dashboard/network/Networks.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
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 { ReactFlow, Controls, Background, ConnectionLineType } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations();
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [edges, setEdges] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFlowData = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/flowchart");
|
||||
const data = await response.json();
|
||||
|
||||
setNodes(data.nodes);
|
||||
setEdges(data.edges);
|
||||
} catch (error) {
|
||||
console.error("Error loading flowchart:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFlowData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex flex-col h-screen">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 dark:text-white" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 h-4 dark:bg-slate-700"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
/
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block dark:text-slate-500" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
{t('Network.Breadcrumb.MyInfrastructure')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block dark:text-slate-500" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="dark:text-slate-300">
|
||||
{t('Network.Breadcrumb.Network')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 pl-4 pr-4">
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className="dark:bg-black rounded-lg"
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
connectionLineType={ConnectionLineType.Straight}
|
||||
className="dark:[&_.react-flow__edge-path]:stroke-slate-500"
|
||||
>
|
||||
<Background
|
||||
color="#64748b"
|
||||
gap={40}
|
||||
className="dark:opacity-20"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
59
app/dashboard/network/page.tsx
Normal file
59
app/dashboard/network/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 Networks from "./Networks"
|
||||
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 ? <Networks /> : null;
|
||||
}
|
||||
59
app/dashboard/page.tsx
Normal file
59
app/dashboard/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 Dashboard from "./Dashboard"
|
||||
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 ? <Dashboard /> : null;
|
||||
}
|
||||
2116
app/dashboard/servers/Servers.tsx
Normal file
2116
app/dashboard/servers/Servers.tsx
Normal file
File diff suppressed because it is too large
Load Diff
897
app/dashboard/servers/[server_id]/Server.tsx
Normal file
897
app/dashboard/servers/[server_id]/Server.tsx
Normal file
@ -0,0 +1,897 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import Chart from 'chart.js/auto'
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Link, Cpu, MicroscopeIcon as Microchip, MemoryStick, HardDrive, MonitorIcon as MonitorCog, FileDigit, History } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { StatusIndicator } from "@/components/status-indicator"
|
||||
import { DynamicIcon } from "lucide-react/dynamic"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import NextLink from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface ServerHistory {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
cpu: (number | null)[];
|
||||
ram: (number | null)[];
|
||||
disk: (number | null)[];
|
||||
online: (boolean | null)[];
|
||||
gpu: (number | null)[];
|
||||
temp: (number | null)[];
|
||||
}
|
||||
}
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
host: boolean;
|
||||
hostServer: number | null;
|
||||
os?: string;
|
||||
ip?: string;
|
||||
url?: string;
|
||||
cpu?: string;
|
||||
gpu?: string;
|
||||
ram?: string;
|
||||
disk?: string;
|
||||
hostedVMs?: Server[];
|
||||
isVM?: boolean;
|
||||
monitoring?: boolean;
|
||||
monitoringURL?: string;
|
||||
online?: boolean;
|
||||
cpuUsage: number;
|
||||
ramUsage: number;
|
||||
diskUsage: number;
|
||||
gpuUsage: number;
|
||||
temp: number;
|
||||
history?: ServerHistory;
|
||||
port: number;
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
interface GetServersResponse {
|
||||
servers: Server[];
|
||||
maxPage: number;
|
||||
}
|
||||
|
||||
export default function ServerDetail() {
|
||||
const t = useTranslations()
|
||||
const params = useParams()
|
||||
const serverId = params.server_id as string
|
||||
const [server, setServer] = useState<Server | null>(null)
|
||||
const [timeRange, setTimeRange] = useState<'1h' | '1d' | '7d' | '30d'>('1h')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Chart references
|
||||
const cpuChartRef = { current: null as Chart | null }
|
||||
const ramChartRef = { current: null as Chart | null }
|
||||
const diskChartRef = { current: null as Chart | null }
|
||||
const gpuChartRef = { current: null as Chart | null }
|
||||
const tempChartRef = { current: null as Chart | null }
|
||||
|
||||
const fetchServerDetails = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.post<GetServersResponse>("/api/servers/get", {
|
||||
serverId: parseInt(serverId),
|
||||
timeRange: timeRange
|
||||
})
|
||||
|
||||
if (response.data.servers && response.data.servers.length > 0) {
|
||||
setServer(response.data.servers[0])
|
||||
}
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch server details:", error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerDetails()
|
||||
}, [serverId, timeRange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!server || !server.history) return;
|
||||
|
||||
// Clean up existing charts
|
||||
if (cpuChartRef.current) cpuChartRef.current.destroy();
|
||||
if (ramChartRef.current) ramChartRef.current.destroy();
|
||||
if (diskChartRef.current) diskChartRef.current.destroy();
|
||||
if (gpuChartRef.current) gpuChartRef.current.destroy();
|
||||
if (tempChartRef.current) tempChartRef.current.destroy();
|
||||
|
||||
// Wait for DOM to be ready
|
||||
const initTimer = setTimeout(() => {
|
||||
const history = server.history as ServerHistory;
|
||||
|
||||
// Format time labels based on the selected time range
|
||||
const timeLabels = history.labels.map((date: string) => {
|
||||
const d = new Date(date)
|
||||
if (timeRange === '1h') {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (timeRange === '1d') {
|
||||
// For 1 day, show hours and minutes
|
||||
return d.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} else if (timeRange === '7d') {
|
||||
// For 7 days, show day and time
|
||||
return d.toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
}) + ' ' + d.toLocaleTimeString([], {
|
||||
hour: '2-digit'
|
||||
})
|
||||
} else {
|
||||
// For 30 days
|
||||
return d.toLocaleDateString([], {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Create a time range title for the chart
|
||||
const getRangeTitle = () => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(history.labels[0])
|
||||
|
||||
if (timeRange === '1h') {
|
||||
return `Last Hour (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
|
||||
} else if (timeRange === '1d') {
|
||||
return `Last 24 Hours (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
|
||||
} else if (timeRange === '7d') {
|
||||
return `Last 7 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
|
||||
} else {
|
||||
return `Last 30 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
|
||||
}
|
||||
}
|
||||
|
||||
// Directly hardcode the y-axis maximum in each chart option
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
autoSkip: false,
|
||||
callback: function(value: any) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Usage %'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
},
|
||||
line: {
|
||||
tension: 0.4,
|
||||
spanGaps: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create charts with very explicit y-axis max values
|
||||
const cpuCanvas = document.getElementById(`cpu-chart`) as HTMLCanvasElement
|
||||
if (cpuCanvas) {
|
||||
cpuChartRef.current = new Chart(cpuCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.CPU') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.cpu,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.CPU') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ramCanvas = document.getElementById(`ram-chart`) as HTMLCanvasElement
|
||||
if (ramCanvas) {
|
||||
ramChartRef.current = new Chart(ramCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.RAM') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.ram,
|
||||
borderColor: 'rgb(153, 102, 255)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.RAM') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const diskCanvas = document.getElementById(`disk-chart`) as HTMLCanvasElement
|
||||
if (diskCanvas) {
|
||||
diskChartRef.current = new Chart(diskCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.Disk') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.disk,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.Disk') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100 // Force this to ensure it's applied
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const gpuCanvas = document.getElementById(`gpu-chart`) as HTMLCanvasElement
|
||||
if (gpuCanvas) {
|
||||
gpuChartRef.current = new Chart(gpuCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.GPU') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.gpu,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.GPU') + ' ' + t('Common.Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tempCanvas = document.getElementById(`temp-chart`) as HTMLCanvasElement
|
||||
if (tempCanvas) {
|
||||
tempChartRef.current = new Chart(tempCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: t('Common.Server.Temperature') + ' ' + t('Common.Server.Usage'),
|
||||
data: history.datasets.temp,
|
||||
borderColor: 'rgb(255, 159, 64)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||
fill: true,
|
||||
spanGaps: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('Common.Server.Temperature') + ' ' + t('Server.UsageHistory'),
|
||||
font: {
|
||||
size: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems: any) {
|
||||
return timeLabels[tooltipItems[0].dataIndex];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return value + '°C';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimer);
|
||||
if (cpuChartRef.current) cpuChartRef.current.destroy();
|
||||
if (ramChartRef.current) ramChartRef.current.destroy();
|
||||
if (diskChartRef.current) diskChartRef.current.destroy();
|
||||
if (gpuChartRef.current) gpuChartRef.current.destroy();
|
||||
if (tempChartRef.current) tempChartRef.current.destroy();
|
||||
};
|
||||
}, [server, timeRange]);
|
||||
|
||||
// Function to refresh data
|
||||
const refreshData = () => {
|
||||
fetchServerDetails()
|
||||
}
|
||||
|
||||
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>{t('Servers.MyInfrastructure')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<NextLink href="/dashboard/servers" className="hover:underline">
|
||||
<BreadcrumbPage>{t('Servers.Title')}</BreadcrumbPage>
|
||||
</NextLink>
|
||||
</BreadcrumbItem>
|
||||
{server && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{server.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<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 clipPath="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"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="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">{t('Common.Loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : server ? (
|
||||
<div className="space-y-6">
|
||||
{/* Server header card */}
|
||||
<Card>
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{server.icon && <DynamicIcon name={server.icon as any} size={32} />}
|
||||
<div>
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{server.os || t('Common.Server.OS')} • {server.isVM ? t('Server.VM') : t('Server.Physical')}
|
||||
{server.isVM && server.hostServer && (
|
||||
<> • {t('Server.HostedOn')} {server.hostedVMs?.[0]?.name}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{server.monitoring && (
|
||||
<div className="absolute top-0 right-4 flex flex-col items-end">
|
||||
<StatusIndicator isOnline={server.online} />
|
||||
{server.online && server.uptime && (
|
||||
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
|
||||
{t('Common.since', { date: server.uptime })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.Hardware')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.CPU')}:</div>
|
||||
<div>{server.cpu || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.GPU')}:</div>
|
||||
<div>{server.gpu || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.RAM')}:</div>
|
||||
<div>{server.ram || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Disk')}:</div>
|
||||
<div>{server.disk || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.Network')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.IP')}:</div>
|
||||
<div>{server.ip || "-"}</div>
|
||||
<div className="text-muted-foreground">{t('Server.ManagementURL')}:</div>
|
||||
<div>
|
||||
{server.url ? (
|
||||
<a href={server.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-500 hover:underline">
|
||||
{server.url} <Link className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.monitoring && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('Server.CurrentUsage')}</h3>
|
||||
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
|
||||
<div className="text-muted-foreground">{t('Common.Server.CPU')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.cpuUsage > 80 ? "bg-destructive" : server.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.cpuUsage !== null && server.cpuUsage !== undefined ? `${server.cpuUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.RAM')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.ramUsage > 80 ? "bg-destructive" : server.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.ramUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.ramUsage !== null && server.ramUsage !== undefined ? `${server.ramUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Disk')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.diskUsage > 80 ? "bg-destructive" : server.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.diskUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.diskUsage !== null && server.diskUsage !== undefined ? `${server.diskUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
{server.gpuUsage && server.gpuUsage !== null && server.gpuUsage !== undefined && server.gpuUsage.toString() !== "0" && (
|
||||
<>
|
||||
<div className="text-muted-foreground">{t('Common.Server.GPU')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.gpuUsage && server.gpuUsage > 80 ? "bg-destructive" : server.gpuUsage && server.gpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${server.gpuUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.gpuUsage && server.gpuUsage !== null && server.gpuUsage !== undefined ? `${server.gpuUsage}%` : t('Common.noData')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{server.temp && server.temp !== null && server.temp !== undefined && server.temp.toString() !== "0" && (
|
||||
<>
|
||||
<div className="text-muted-foreground">{t('Common.Server.Temperature')}:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${server.temp && server.temp > 80 ? "bg-destructive" : server.temp && server.temp > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${Math.min(server.temp || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{server.temp !== null && server.temp !== undefined && server.temp !== 0 ? `${server.temp}°C` : t('Common.noData')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Charts */}
|
||||
{server.monitoring && server.history && (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('Server.ResourceUsageHistory')}</CardTitle>
|
||||
<CardDescription>
|
||||
{timeRange === '1h'
|
||||
? t('Server.TimeRange.LastHour')
|
||||
: timeRange === '1d'
|
||||
? t('Server.TimeRange.Last24Hours')
|
||||
: timeRange === '7d'
|
||||
? t('Server.TimeRange.Last7Days')
|
||||
: t('Server.TimeRange.Last30Days')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={timeRange} onValueChange={(value: '1h' | '1d' | '7d' | '30d') => setTimeRange(value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('Server.TimeRange.Select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">{t('Server.TimeRange.LastHour')}</SelectItem>
|
||||
<SelectItem value="1d">{t('Server.TimeRange.Last24Hours')}</SelectItem>
|
||||
<SelectItem value="7d">{t('Server.TimeRange.Last7Days')}</SelectItem>
|
||||
<SelectItem value="30d">{t('Server.TimeRange.Last30Days')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={refreshData}>{t('Common.Refresh')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="cpu-chart" />
|
||||
</div>
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="ram-chart" />
|
||||
</div>
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="disk-chart" />
|
||||
</div>
|
||||
{server.history?.datasets.gpu.some(value => value !== null && value !== 0) && (
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="gpu-chart" />
|
||||
</div>
|
||||
)}
|
||||
{server.history?.datasets.temp.some(value => value !== null && value !== 0) && (
|
||||
<div className="h-[200px] relative bg-background">
|
||||
<canvas id="temp-chart" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Machines */}
|
||||
{server.hostedVMs && server.hostedVMs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('Server.VirtualMachines')}</CardTitle>
|
||||
<CardDescription>{t('Server.VirtualMachinesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{server.hostedVMs.map((hostedVM) => (
|
||||
<div
|
||||
key={hostedVM.id}
|
||||
className="flex flex-col gap-2 border border-muted py-2 px-4 rounded-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{hostedVM.icon && (
|
||||
<DynamicIcon
|
||||
name={hostedVM.icon as any}
|
||||
size={24}
|
||||
/>
|
||||
)}
|
||||
<NextLink href={`/dashboard/servers/${hostedVM.id}`} className="hover:underline">
|
||||
<div className="text-base font-extrabold">
|
||||
{hostedVM.icon && "・ "}
|
||||
{hostedVM.name}
|
||||
</div>
|
||||
</NextLink>
|
||||
</div>
|
||||
{hostedVM.monitoring && (
|
||||
<div className="flex flex-col items-end">
|
||||
<StatusIndicator isOnline={hostedVM.online} />
|
||||
{hostedVM.online && hostedVM.uptime && (
|
||||
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
|
||||
{t('Common.since', { date: hostedVM.uptime })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-full pb-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5 pb-2">
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<MonitorCog className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.OS')}:</b> {hostedVM.os || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<FileDigit className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.IP')}:</b> {hostedVM.ip || t('Common.notSet')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full mb-2">
|
||||
<h4 className="text-sm font-semibold">{t('Server.HardwareInformation')}</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.CPU')}:</b> {hostedVM.cpu || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<Microchip className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.GPU')}:</b> {hostedVM.gpu || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.RAM')}:</b> {hostedVM.ram || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/80">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
<b>{t('Common.Server.Disk')}:</b> {hostedVM.disk || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hostedVM.monitoring && (
|
||||
<>
|
||||
<div className="col-span-full pt-2 pb-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="col-span-full grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.CPU')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.cpuUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.cpuUsage && hostedVM.cpuUsage > 80 ? "bg-destructive" : hostedVM.cpuUsage && hostedVM.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.cpuUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.RAM')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.ramUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.ramUsage && hostedVM.ramUsage > 80 ? "bg-destructive" : hostedVM.ramUsage && hostedVM.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.ramUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{t('Common.Server.Disk')}</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{hostedVM.diskUsage || 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
|
||||
<div
|
||||
className={`h-full ${hostedVM.diskUsage && hostedVM.diskUsage > 80 ? "bg-destructive" : hostedVM.diskUsage && hostedVM.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||
style={{ width: `${hostedVM.diskUsage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-12">
|
||||
<h2 className="text-2xl font-bold">{t('Server.NotFound')}</h2>
|
||||
<p className="text-muted-foreground mt-2">{t('Server.NotFoundDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
59
app/dashboard/servers/[server_id]/page.tsx
Normal file
59
app/dashboard/servers/[server_id]/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 ServerDetail from "./Server"
|
||||
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 clipPath='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' strokeWidth='1.4' strokeLinecap='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 ? <ServerDetail /> : null;
|
||||
}
|
||||
59
app/dashboard/servers/page.tsx
Normal file
59
app/dashboard/servers/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 Servers from "./Servers"
|
||||
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 ? <Servers /> : null;
|
||||
}
|
||||
868
app/dashboard/settings/Settings.tsx
Normal file
868
app/dashboard/settings/Settings.tsx
Normal file
@ -0,0 +1,868 @@
|
||||
"use client"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useEffect, useState } from "react"
|
||||
import axios from "axios"
|
||||
import Cookies from "js-cookie"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2, Play, Languages } from "lucide-react"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { toast } from "sonner"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications: any[]
|
||||
}
|
||||
interface NotificationResponse {
|
||||
notification_text_application?: string
|
||||
notification_text_server?: string
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const t = useTranslations()
|
||||
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 [notificationType, setNotificationType] = useState<string>("")
|
||||
const [notificationName, setNotificationName] = useState<string>("")
|
||||
const [smtpHost, setSmtpHost] = useState<string>("")
|
||||
const [smtpPort, setSmtpPort] = useState<number>(0)
|
||||
const [smtpSecure, setSmtpSecure] = useState<boolean>(false)
|
||||
const [smtpUsername, setSmtpUsername] = useState<string>("")
|
||||
const [smtpPassword, setSmtpPassword] = useState<string>("")
|
||||
const [smtpFrom, setSmtpFrom] = useState<string>("")
|
||||
const [smtpTo, setSmtpTo] = useState<string>("")
|
||||
const [telegramToken, setTelegramToken] = useState<string>("")
|
||||
const [telegramChatId, setTelegramChatId] = useState<string>("")
|
||||
const [discordWebhook, setDiscordWebhook] = useState<string>("")
|
||||
const [gotifyUrl, setGotifyUrl] = useState<string>("")
|
||||
const [gotifyToken, setGotifyToken] = useState<string>("")
|
||||
const [ntfyUrl, setNtfyUrl] = useState<string>("")
|
||||
const [ntfyToken, setNtfyToken] = useState<string>("")
|
||||
const [pushoverUrl, setPushoverUrl] = useState<string>("")
|
||||
const [pushoverToken, setPushoverToken] = useState<string>("")
|
||||
const [pushoverUser, setPushoverUser] = useState<string>("")
|
||||
const [echobellURL, setEchobellURL] = useState<string>("")
|
||||
const [language, setLanguage] = useState<string>("english")
|
||||
const [notifications, setNotifications] = useState<any[]>([])
|
||||
|
||||
const [notificationTextApplication, setNotificationTextApplication] = useState<string>("")
|
||||
const [notificationTextServer, setNotificationTextServer] = useState<string>("")
|
||||
|
||||
const changeEmail = async () => {
|
||||
setEmailErrorVisible(false)
|
||||
setEmailSuccess(false)
|
||||
setEmailError("")
|
||||
|
||||
if (!email) {
|
||||
setEmailError(t('Settings.UserSettings.ChangeEmail.EmailRequired'))
|
||||
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(t('Settings.UserSettings.ChangePassword.PasswordsDontMatch'))
|
||||
setPasswordErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
if (!oldPassword || !password || !confirmPassword) {
|
||||
setPasswordError(t('Settings.UserSettings.ChangePassword.AllFieldsRequired'))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const addNotification = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/add", {
|
||||
name: notificationName,
|
||||
type: notificationType,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpUsername: smtpUsername,
|
||||
smtpPassword: smtpPassword,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpTo: smtpTo,
|
||||
telegramToken: telegramToken,
|
||||
telegramChatId: telegramChatId,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
pushoverUrl: pushoverUrl,
|
||||
pushoverToken: pushoverToken,
|
||||
pushoverUser: pushoverUser,
|
||||
echobellURL: echobellURL,
|
||||
})
|
||||
getNotifications()
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/delete", {
|
||||
id: id,
|
||||
})
|
||||
if (response.status === 200) {
|
||||
getNotifications()
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotifications = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationsResponse>("/api/notifications/get", {})
|
||||
if (response.status === 200 && response.data) {
|
||||
setNotifications(response.data.notifications)
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotifications()
|
||||
}, [])
|
||||
|
||||
const getNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationResponse>("/api/settings/get_notification_text", {})
|
||||
if (response.status === 200) {
|
||||
if (response.data.notification_text_application) {
|
||||
setNotificationTextApplication(response.data.notification_text_application)
|
||||
} else {
|
||||
setNotificationTextApplication("The application !name (!url) is now !status.")
|
||||
}
|
||||
if (response.data.notification_text_server) {
|
||||
setNotificationTextServer(response.data.notification_text_server)
|
||||
} else {
|
||||
setNotificationTextServer("The server !name is now !status.")
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const editNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/settings/notification_text", {
|
||||
text_application: notificationTextApplication,
|
||||
text_server: notificationTextServer,
|
||||
})
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationText()
|
||||
}, [])
|
||||
|
||||
const testNotification = async (id: number) => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/test", {
|
||||
notificationId: id,
|
||||
})
|
||||
toast.success(t('Settings.Notifications.TestSuccess'))
|
||||
} catch (error: any) {
|
||||
toast.error(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const language = Cookies.get("language")
|
||||
if (language === "en") {
|
||||
setLanguage("english")
|
||||
} else if (language === "de") {
|
||||
setLanguage("german")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setLanguageFunc = (value: string) => {
|
||||
setLanguage(value)
|
||||
if (value === "english") {
|
||||
Cookies.set("language", "en")
|
||||
} else if (value === "german") {
|
||||
Cookies.set("language", "de")
|
||||
}
|
||||
// Reload the page
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
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>{t('Settings.Breadcrumb.Dashboard')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Settings.Breadcrumb.Settings')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-6">
|
||||
<div className="pb-4">
|
||||
<span className="text-3xl font-bold">{t('Settings.Title')}</span>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t('Settings.UserSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.UserSettings.Description')}
|
||||
</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">{t('Settings.UserSettings.ChangeEmail.Title')}</h3>
|
||||
</div>
|
||||
|
||||
{emailErrorVisible && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t('Common.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>{t('Settings.UserSettings.ChangeEmail.Success')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('Settings.UserSettings.ChangeEmail.Placeholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Button onClick={changeEmail} className="w-full h-11">
|
||||
{t('Settings.UserSettings.ChangeEmail.Button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="font-semibold text-lg">{t('Settings.UserSettings.ChangePassword.Title')}</h3>
|
||||
</div>
|
||||
|
||||
{passwordErrorVisible && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t('Common.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>{t('Settings.UserSettings.ChangePassword.Success')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.OldPassword')}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.NewPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('Settings.UserSettings.ChangePassword.ConfirmPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<Button onClick={changePassword} className="w-full h-11">
|
||||
{t('Settings.UserSettings.ChangePassword.Button')}
|
||||
</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">{t('Settings.ThemeSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.ThemeSettings.Description')}
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select value={theme} onValueChange={(value: string) => setTheme(value)}>
|
||||
<SelectTrigger className="w-full h-11">
|
||||
<SelectValue>
|
||||
{t(`Settings.ThemeSettings.${(theme ?? "system").charAt(0).toUpperCase() + (theme ?? "system").slice(1)}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">{t('Settings.ThemeSettings.Light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('Settings.ThemeSettings.Dark')}</SelectItem>
|
||||
<SelectItem value="system">{t('Settings.ThemeSettings.System')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<Languages className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t('Settings.LanguageSettings.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.LanguageSettings.Description')}
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Select value={language} onValueChange={(value: string) => setLanguageFunc(value)}>
|
||||
<SelectTrigger className="w-full h-11">
|
||||
<SelectValue>
|
||||
{t(`Settings.LanguageSettings.${(language ?? "english").charAt(0).toUpperCase() + (language ?? "english").slice(1)}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="english">{t('Settings.LanguageSettings.English')}</SelectItem>
|
||||
<SelectItem value="german">{t('Settings.LanguageSettings.German')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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-3">
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('Settings.Notifications.Title')}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
{t('Settings.Notifications.Description')}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11 flex items-center gap-2">
|
||||
{t('Settings.Notifications.AddChannel')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t('Settings.Notifications.AddNotification.Title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
id="notificationName"
|
||||
placeholder={t('Settings.Notifications.AddNotification.Name')}
|
||||
onChange={(e) => setNotificationName(e.target.value)}
|
||||
/>
|
||||
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('Settings.Notifications.AddNotification.Type')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">{t('Settings.Notifications.AddNotification.SMTP.Title')}</SelectItem>
|
||||
<SelectItem value="telegram">{t('Settings.Notifications.AddNotification.Telegram.Title')}</SelectItem>
|
||||
<SelectItem value="discord">{t('Settings.Notifications.AddNotification.Discord.Title')}</SelectItem>
|
||||
<SelectItem value="gotify">{t('Settings.Notifications.AddNotification.Gotify.Title')}</SelectItem>
|
||||
<SelectItem value="ntfy">{t('Settings.Notifications.AddNotification.Ntfy.Title')}</SelectItem>
|
||||
<SelectItem value="pushover">{t('Settings.Notifications.AddNotification.Pushover.Title')}</SelectItem>
|
||||
<SelectItem value="echobell">{t('Settings.Notifications.AddNotification.Echobell.Title')}</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
{notificationType === "smtp" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Host')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="smtp.example.com"
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="587"
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2 pb-4">
|
||||
<Checkbox id="smtpSecure" onCheckedChange={(checked: any) => setSmtpSecure(checked)} />
|
||||
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
|
||||
{t('Settings.Notifications.AddNotification.SMTP.Secure')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Username')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="user@example.com"
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.Password')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.From')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
onChange={(e) => setSmtpFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.SMTP.To')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
onChange={(e) => setSmtpTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "telegram" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Telegram.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setTelegramToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Telegram.ChatId')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "discord" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Discord.Webhook')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setDiscordWebhook(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "gotify" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Gotify.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setGotifyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Gotify.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setGotifyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "ntfy" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Ntfy.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Ntfy.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "pushover" && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.Token')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Pushover.User')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => setPushoverUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "echobell" && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label>{t('Settings.Notifications.AddNotification.Echobell.Url')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. https://hook.echobell.one/t/xxx"
|
||||
onChange={(e) => setEchobellURL(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{t('Settings.Notifications.AddNotification.Echobell.AddMessage')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Select>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={addNotification}>{t('Common.add')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11" variant="outline">
|
||||
{t('Settings.Notifications.CustomizeText.Display')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t('Settings.Notifications.CustomizeText.Title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.CustomizeText.Application')}</Label>
|
||||
<Textarea
|
||||
value={notificationTextApplication}
|
||||
onChange={(e) => setNotificationTextApplication(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('Settings.Notifications.CustomizeText.Server')}</Label>
|
||||
<Textarea
|
||||
value={notificationTextServer}
|
||||
onChange={(e) => setNotificationTextServer(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
{t('Settings.Notifications.CustomizeText.Placeholders.Title')}
|
||||
<ul className="list-disc list-inside space-y-1 pt-2">
|
||||
<li>
|
||||
<b>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Title')}</b>
|
||||
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Name')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Server.Status')}</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Title')}</b>
|
||||
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Name')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Url')}</li>
|
||||
<li>{t('Settings.Notifications.CustomizeText.Placeholders.Application.Status')}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={editNotificationText}>
|
||||
{t('Common.Save')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4">{t('Settings.Notifications.ActiveChannels')}</h3>
|
||||
<div className="space-y-3">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{notification.type === "smtp" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<AtSign className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "telegram" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Send className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "discord" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "gotify" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "ntfy" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "pushover" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium capitalize">
|
||||
{notification.name ||
|
||||
t(`Settings.Notifications.AddNotification.${notification.type.charAt(0).toUpperCase() + notification.type.slice(1)}.Title`)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`Settings.Notifications.AddNotification.${notification.type.charAt(0).toUpperCase() + notification.type.slice(1)}.Description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-muted/20"
|
||||
onClick={() => testNotification(notification.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{t('Settings.Notifications.Test')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:bg-muted/20"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t('Settings.Notifications.Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/5">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="bg-muted/20 p-3 rounded-full">
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{t('Settings.Notifications.NoNotifications')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{t('Settings.Notifications.NoNotificationsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Toaster />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
59
app/dashboard/settings/page.tsx
Normal file
59
app/dashboard/settings/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 Settings from "./Settings"
|
||||
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 ? <Settings /> : null;
|
||||
}
|
||||
434
app/dashboard/uptime/Uptime.tsx
Normal file
434
app/dashboard/uptime/Uptime.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
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 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";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const timeFormats = {
|
||||
1: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
2: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
3: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleDateString([], {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
}),
|
||||
4: (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleDateString([], {
|
||||
day: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
};
|
||||
|
||||
const minBoxWidths = {
|
||||
1: 20,
|
||||
2: 20,
|
||||
3: 24,
|
||||
4: 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 t = useTranslations();
|
||||
const [data, setData] = useState<UptimeData[]>([]);
|
||||
const [timespan, setTimespan] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [pagination, setPagination] = useState<PaginationData>({
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const savedItemsPerPage = Cookies.get("itemsPerPage-uptime");
|
||||
const defaultItemsPerPage = 5;
|
||||
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
|
||||
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getData = async (selectedTimespan: number, page: number, itemsPerPage: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.post<{
|
||||
data: UptimeData[];
|
||||
pagination: PaginationData;
|
||||
}>("/api/applications/uptime", {
|
||||
timespan: selectedTimespan,
|
||||
page,
|
||||
itemsPerPage
|
||||
});
|
||||
|
||||
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, itemsPerPage);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
|
||||
setPagination(prev => ({...prev, currentPage: newPage}));
|
||||
getData(timespan, newPage, itemsPerPage);
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const newItemsPerPage = parseInt(value);
|
||||
|
||||
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
|
||||
toast.error(t('Uptime.Messages.NumberValidation'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
|
||||
|
||||
setItemsPerPage(validatedValue);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
|
||||
expires: 365,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
getData(timespan, 1, validatedValue);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getData(timespan, 1, itemsPerPage);
|
||||
}, [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>{t('Uptime.Breadcrumb.MyInfrastructure')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{t('Uptime.Breadcrumb.Uptime')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<Toaster />
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-3xl font-bold">{t('Uptime.Title')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={String(itemsPerPage)}
|
||||
onValueChange={handleItemsPerPageChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && customInputRef.current) {
|
||||
customInputRef.current.value = String(itemsPerPage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue>
|
||||
{itemsPerPage} {itemsPerPage === 1 ? t('Common.ItemsPerPage.item') : t('Common.ItemsPerPage.items')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
|
||||
<SelectItem value={String(itemsPerPage)}>
|
||||
{itemsPerPage} {itemsPerPage === 1 ? t('Common.ItemsPerPage.item') : t('Common.ItemsPerPage.items')} (custom)
|
||||
</SelectItem>
|
||||
) : null}
|
||||
<SelectItem value="5">{t('Common.ItemsPerPage.5')}</SelectItem>
|
||||
<SelectItem value="10">{t('Common.ItemsPerPage.10')}</SelectItem>
|
||||
<SelectItem value="15">{t('Common.ItemsPerPage.15')}</SelectItem>
|
||||
<SelectItem value="20">{t('Common.ItemsPerPage.20')}</SelectItem>
|
||||
<SelectItem value="25">{t('Common.ItemsPerPage.25')}</SelectItem>
|
||||
<div className="p-2 border-t mt-1">
|
||||
<Label htmlFor="custom-items" className="text-xs font-medium">{t('Common.ItemsPerPage.Custom')}</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Input
|
||||
id="custom-items"
|
||||
ref={customInputRef}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
className="h-8"
|
||||
defaultValue={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (isNaN(value) || value < 1 || value > 100) {
|
||||
e.target.classList.add("border-red-500");
|
||||
} else {
|
||||
e.target.classList.remove("border-red-500");
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value >= 1 && value <= 100) {
|
||||
handleItemsPerPageChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
const value = parseInt((e.target as HTMLInputElement).value);
|
||||
if (value >= 1 && value <= 100) {
|
||||
const validatedValue = Math.min(Math.max(value, 1), 100);
|
||||
setItemsPerPage(validatedValue);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
|
||||
expires: 365,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
getData(timespan, 1, validatedValue);
|
||||
document.body.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{t('Common.ItemsPerPage.items')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(timespan)}
|
||||
onValueChange={(v) => {
|
||||
setTimespan(Number(v) as 1 | 2 | 3 | 4);
|
||||
setPagination(prev => ({...prev, currentPage: 1}));
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('Uptime.TimeRange.Select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">{t('Uptime.TimeRange.LastHour')}</SelectItem>
|
||||
<SelectItem value="2">{t('Uptime.TimeRange.LastDay')}</SelectItem>
|
||||
<SelectItem value="3">{t('Uptime.TimeRange.Last7Days')}</SelectItem>
|
||||
<SelectItem value="4">{t('Uptime.TimeRange.Last30Days')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">{t('Uptime.Messages.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">
|
||||
{new Date(entry.timestamp).toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: timespan > 2 ? 'numeric' : undefined,
|
||||
hour: '2-digit',
|
||||
minute: timespan === 1 ? '2-digit' : undefined,
|
||||
hour12: false
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{entry.missing
|
||||
? t('Uptime.Status.NoData')
|
||||
: entry.online
|
||||
? t('Uptime.Status.Online')
|
||||
: t('Uptime.Status.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">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pagination.totalItems > 0
|
||||
? t('Uptime.Pagination.Showing', {
|
||||
start: ((pagination.currentPage - 1) * itemsPerPage) + 1,
|
||||
end: Math.min(pagination.currentPage * itemsPerPage, pagination.totalItems),
|
||||
total: pagination.totalItems
|
||||
})
|
||||
: t('Uptime.Messages.NoItems')}
|
||||
</div>
|
||||
</div>
|
||||
<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;
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
120
app/globals.css
Normal file
120
app/globals.css
Normal file
@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.147 0.004 49.25);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.147 0.004 49.25);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||
--primary: oklch(0.216 0.006 56.043);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.97 0.001 106.424);
|
||||
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||
--muted: oklch(0.97 0.001 106.424);
|
||||
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||
--accent: oklch(0.97 0.001 106.424);
|
||||
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.923 0.003 48.717);
|
||||
--input: oklch(0.923 0.003 48.717);
|
||||
--ring: oklch(0.709 0.01 56.259);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.001 106.423);
|
||||
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.147 0.004 49.25);
|
||||
--foreground: oklch(0.985 0.001 106.423);
|
||||
--card: oklch(0.216 0.006 56.043);
|
||||
--card-foreground: oklch(0.985 0.001 106.423);
|
||||
--popover: oklch(0.216 0.006 56.043);
|
||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||
--primary: oklch(0.923 0.003 48.717);
|
||||
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||
--secondary: oklch(0.268 0.007 34.298);
|
||||
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||
--muted: oklch(0.268 0.007 34.298);
|
||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||
--accent: oklch(0.268 0.007 34.298);
|
||||
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.553 0.013 58.071);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.216 0.006 56.043);
|
||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
51
app/layout.tsx
Normal file
51
app/layout.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import {NextIntlClientProvider} from 'next-intl';
|
||||
import {getLocale} from 'next-intl/server';
|
||||
import {cookies} from 'next/headers';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CoreControl",
|
||||
description: "The only Dashboard you will need for your Services",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get('language')?.value || 'en';
|
||||
const messages = (await import(`@/i18n/languages/${locale}.json`)).default;
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
150
app/page.tsx
Normal file
150
app/page.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Cookies from "js-cookie"
|
||||
import { useRouter } from "next/navigation"
|
||||
import axios from "axios"
|
||||
import { AlertCircle, KeyRound, Mail, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('Home');
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [rememberMe, setRememberMe] = useState(false)
|
||||
const router = useRouter()
|
||||
const [error, setError] = useState("")
|
||||
const [errorVisible, setErrorVisible] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token")
|
||||
if (token) {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
if (!username || !password) {
|
||||
setError("Please enter both email and password")
|
||||
setErrorVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await axios.post("/api/auth/login", { username, password })
|
||||
const { token } = response.data as LoginResponse
|
||||
|
||||
const cookieOptions = rememberMe ? { expires: 7 } : {}
|
||||
Cookies.set("token", token, cookieOptions)
|
||||
|
||||
router.push("/dashboard")
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || "Login failed. Please try again.")
|
||||
setErrorVisible(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">CoreControl</h1>
|
||||
<p className="text-muted-foreground">{t('LoginCardDescription')}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-muted/40 shadow-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-semibold">{t('LoginCardTitle')}</CardTitle>
|
||||
<CardDescription>{t('LoginCardDescription')}</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>{t('AuthenticationError')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
{t('Email')}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="mail@example.com"
|
||||
className="pl-10"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
{t('Password')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="pl-10"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button className="w-full" onClick={login} disabled={isLoading}>
|
||||
{isLoading ? t('SigninButtonSigningIn') : t('SigninButton')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
211
components/app-sidebar.tsx
Normal file
211
components/app-sidebar.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
AppWindow,
|
||||
Settings,
|
||||
LayoutDashboardIcon,
|
||||
Briefcase,
|
||||
Server,
|
||||
Network,
|
||||
Activity,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarFooter,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import Cookies from "js-cookie"
|
||||
import { useRouter } from "next/navigation"
|
||||
import packageJson from "@/package.json"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { useTranslations } from "next-intl"
|
||||
interface NavItem {
|
||||
title: string
|
||||
icon?: React.ComponentType<any>
|
||||
url: string
|
||||
isActive?: boolean
|
||||
items?: NavItem[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const t = useTranslations('Sidebar')
|
||||
const data: { navMain: NavItem[] } = {
|
||||
navMain: [
|
||||
{
|
||||
title: t('Dashboard'),
|
||||
icon: LayoutDashboardIcon,
|
||||
url: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: t('My Infrastructure'),
|
||||
url: "#",
|
||||
icon: Briefcase,
|
||||
items: [
|
||||
{
|
||||
title: t('Servers'),
|
||||
icon: Server,
|
||||
url: "/dashboard/servers",
|
||||
},
|
||||
{
|
||||
title: t('Applications'),
|
||||
icon: AppWindow,
|
||||
url: "/dashboard/applications",
|
||||
},
|
||||
{
|
||||
title: t('Uptime'),
|
||||
icon: Activity,
|
||||
url: "/dashboard/uptime",
|
||||
},
|
||||
{
|
||||
title: t('Network'),
|
||||
icon: Network,
|
||||
url: "/dashboard/network",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('Settings'),
|
||||
icon: Settings,
|
||||
url: "/dashboard/settings",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const logout = async () => {
|
||||
Cookies.remove("token")
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
// Check if a path is active (exact match or starts with path for parent items)
|
||||
const isActive = (path: string) => {
|
||||
if (path === "#") return false
|
||||
return pathname === path || (path !== "/dashboard" && pathname?.startsWith(path))
|
||||
}
|
||||
|
||||
// Check if any child item is active
|
||||
const hasActiveChild = (items?: NavItem[]) => {
|
||||
if (!items) return false
|
||||
return items.some((item) => isActive(item.url))
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader className="border-b border-sidebar-border/30 pb-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild className="gap-3">
|
||||
<a href="https://github.com/crocofied/corecontrol" target="_blank" rel="noreferrer noopener" className="transition-all hover:opacity-80">
|
||||
<div className="flex items-center justify-center rounded-lg overflow-hidden bg-gradient-to-br from-teal-500 to-emerald-600 shadow-sm">
|
||||
<Image src="/logo.png" width={48} height={48} alt="CoreControl Logo" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold text-base">CoreControl</span>
|
||||
<span className="text-xs text-sidebar-foreground/70">v{packageJson.version}</span>
|
||||
</div>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="flex flex-col h-full py-4">
|
||||
<SidebarGroup className="flex-grow">
|
||||
<SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/60 uppercase tracking-wider px-4 mb-2">
|
||||
{t('Main Navigation')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{data.navMain.map((item) =>
|
||||
item.items?.length ? (
|
||||
<Collapsible key={item.title} defaultOpen={hasActiveChild(item.items)} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className={cn(
|
||||
"font-medium transition-all",
|
||||
(hasActiveChild(item.items) || isActive(item.url)) &&
|
||||
"text-sidebar-accent-foreground bg-sidebar-accent/50",
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive(subItem.url)} className="transition-all">
|
||||
<Link href={subItem.url} className="flex items-center">
|
||||
{subItem.icon && <subItem.icon className="h-3.5 w-3.5 mr-2" />}
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
"font-medium transition-all",
|
||||
isActive(item.url) && "text-sidebar-accent-foreground bg-sidebar-accent/50",
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
),
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border/30 pt-4 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10 border-none shadow-none"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
38
components/status-indicator.tsx
Normal file
38
components/status-indicator.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
isOnline?: boolean
|
||||
className?: string
|
||||
showLabel?: boolean
|
||||
pulseAnimation?: boolean
|
||||
}
|
||||
|
||||
export function StatusIndicator({
|
||||
isOnline = false,
|
||||
className,
|
||||
showLabel = true,
|
||||
pulseAnimation = true,
|
||||
}: StatusIndicatorProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1 border-transparent transition-colors duration-300",
|
||||
isOnline
|
||||
? "bg-green-100 dark:bg-green-950/30 text-green-800 dark:text-green-300"
|
||||
: "bg-red-100 dark:bg-red-950/30 text-red-800 dark:text-red-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn("relative flex h-2.5 w-2.5 rounded-full", isOnline ? "bg-green-500" : "bg-red-500")}>
|
||||
{isOnline && pulseAnimation && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
)}
|
||||
</span>
|
||||
{showLabel && <span className="text-xs font-medium">{isOnline ? "Online" : "Offline"}</span>}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
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 }
|
||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
59
components/ui/button.tsx
Normal file
59
components/ui/button.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
127
components/ui/pagination.tsx
Normal file
127
components/ui/pagination.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
31
components/ui/progress.tsx
Normal file
31
components/ui/progress.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
185
components/ui/select.tsx
Normal file
185
components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
726
components/ui/sidebar.tsx
Normal file
726
components/ui/sidebar.tsx
Normal file
@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
25
components/ui/sonner.tsx
Normal file
25
components/ui/sonner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
61
components/ui/tooltip.tsx
Normal file
61
components/ui/tooltip.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
34
compose.yml
Normal file
34
compose.yml
Normal file
@ -0,0 +1,34 @@
|
||||
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"
|
||||
|
||||
agent:
|
||||
image: haedlessdev/corecontrol-agent:latest
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
67
docs/.vitepress/config.mts
Normal file
67
docs/.vitepress/config.mts
Normal file
@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "CoreControl",
|
||||
description: "Dashboard to manage your entire server infrastructure",
|
||||
lastUpdated: true,
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }],
|
||||
],
|
||||
themeConfig: {
|
||||
logo: '/logo.png',
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Installation', link: '/installation' }
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: 'Released under the MIT License.',
|
||||
copyright: 'Copyright © 2025-present CoreControl',
|
||||
},
|
||||
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Deploy',
|
||||
items: [
|
||||
{ text: 'Installation', link: '/installation' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'General',
|
||||
items: [
|
||||
{ text: 'Dashboard', link: '/general/Dashboard' },
|
||||
{ text: 'Servers', link: '/general/Servers' },
|
||||
{ text: 'Applications', link: '/general/Applications' },
|
||||
{ text: 'Uptime', link: '/general/Uptime' },
|
||||
{ text: 'Network', link: '/general/Network' },
|
||||
{ text: 'Settings', link: '/general/Settings' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Notifications',
|
||||
items: [
|
||||
{ text: 'General', link: '/notifications/General' },
|
||||
{ text: 'Email', link: '/notifications/Email' },
|
||||
{ text: 'Telegram', link: '/notifications/Telegram' },
|
||||
{ text: 'Discord', link: '/notifications/Discord' },
|
||||
{ text: 'Gotify', link: '/notifications/Gotify' },
|
||||
{ text: 'Ntfy', link: '/notifications/Ntfy' },
|
||||
{ text: 'Pushover', link: '/notifications/Pushover' },
|
||||
{ text: 'Echobell', link: '/notifications/Echobell' },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/crocofied/corecontrol' },
|
||||
{ icon: 'buymeacoffee', link: 'https://www.buymeacoffee.com/corecontrol' }
|
||||
]
|
||||
}
|
||||
})
|
||||
23
docs/.vitepress/dist/404.html
vendored
Normal file
23
docs/.vitepress/dist/404.html
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-US" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 | CoreControl</title>
|
||||
<meta name="description" content="Not Found">
|
||||
<meta name="generator" content="VitePress v1.6.3">
|
||||
<link rel="preload stylesheet" href="/assets/style.DEOyzpKL.css" as="style">
|
||||
<link rel="preload stylesheet" href="/vp-icons.css" as="style">
|
||||
<script type="module" src="/assets/chunks/metadata.80a3dac1.js"></script>
|
||||
<script type="module" src="/assets/app.C_TDNGCa.js"></script>
|
||||
<link rel="preload" href="/assets/inter-roman-latin.Di8DUHzh.woff2" as="font" type="font/woff2" crossorigin="">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
<script id="check-dark-mode">(()=>{const e=localStorage.getItem("vitepress-theme-appearance")||"auto",a=window.matchMedia("(prefers-color-scheme: dark)").matches;(!e||e==="auto"?a:e==="dark")&&document.documentElement.classList.add("dark")})();</script>
|
||||
<script id="check-mac-os">document.documentElement.classList.toggle("mac",/Mac|iPhone|iPod|iPad/i.test(navigator.platform));</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1
docs/.vitepress/dist/assets/app.C_TDNGCa.js
vendored
Normal file
1
docs/.vitepress/dist/assets/app.C_TDNGCa.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import{t as p}from"./chunks/theme.BsFEzhuB.js";import{R as s,a2 as i,a3 as u,a4 as c,a5 as l,a6 as f,a7 as d,a8 as m,a9 as h,aa as g,ab as A,d as v,u as y,v as C,s as P,ac as b,ad as w,ae as R,af as E}from"./chunks/framework.DPDPlp3K.js";function r(e){if(e.extends){const a=r(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const n=r(p),S=v({name:"VitePressApp",setup(){const{site:e,lang:a,dir:t}=y();return C(()=>{P(()=>{document.documentElement.lang=a.value,document.documentElement.dir=t.value})}),e.value.router.prefetchLinks&&b(),w(),R(),n.setup&&n.setup(),()=>E(n.Layout)}});async function T(){globalThis.__VITEPRESS__=!0;const e=_(),a=D();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",f),a.component("ClientOnly",d),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),n.enhanceApp&&await n.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function D(){return A(S)}function _(){let e=s;return h(a=>{let t=g(a),o=null;return t&&(e&&(t=t.replace(/\.js$/,".lean.js")),o=import(t)),s&&(e=!1),o},n.NotFound)}s&&T().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{T as createApp};
|
||||
BIN
docs/.vitepress/dist/assets/applications_add_button.CTBM75AA.png
vendored
Normal file
BIN
docs/.vitepress/dist/assets/applications_add_button.CTBM75AA.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
docs/.vitepress/dist/assets/applications_display.D1ZCmp63.png
vendored
Normal file
BIN
docs/.vitepress/dist/assets/applications_display.D1ZCmp63.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
docs/.vitepress/dist/assets/chunks/@localSearchIndexroot.WKQwg8wp.js
vendored
Normal file
1
docs/.vitepress/dist/assets/chunks/@localSearchIndexroot.WKQwg8wp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user