mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-04 20:42:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc57db2217 | |||
| 8b687be939 | |||
| 0977d95539 |
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="./canvas_inspirational.png" alt="Containr Logo" width="200">
|
||||
<img src="./containr.svg" alt="Containr Logo" width="200">
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
@@ -24,6 +24,10 @@
|
||||
<a href="#contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./scorecard.png" alt="Code Quality Scorecard" width="100%">
|
||||
</p>
|
||||
|
||||
> **⚠️ Development Status**: This project is currently under active development and is **not yet ready for production use**. The current codebase represents the default structure and foundation for the container management platform. Many features are still being implemented and may not work as expected.
|
||||
|
||||
> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management.
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #877cd6;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #6e65d8;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#linear-gradient2);
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #6e63d7;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #b1abf0;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#linear-gradient1);
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #a39ae9;
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: #8476d5;
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: #aaa2eb;
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: #675bd2;
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: #b9b4f1;
|
||||
}
|
||||
|
||||
.st11 {
|
||||
fill: #5449b9;
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: #655acd;
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: #b6b1f0;
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: #9e93eb;
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: #a298e8;
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: #4239a5;
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: #b0a9ed;
|
||||
}
|
||||
|
||||
.st18 {
|
||||
fill: #3d35a1;
|
||||
}
|
||||
|
||||
.st19 {
|
||||
fill: #8f80e4;
|
||||
}
|
||||
|
||||
.st20 {
|
||||
fill: #9186f3;
|
||||
}
|
||||
|
||||
.st21 {
|
||||
fill: #5a4ec2;
|
||||
}
|
||||
|
||||
.st22 {
|
||||
fill: #423aa4;
|
||||
}
|
||||
|
||||
.st23 {
|
||||
fill: #8e81dc;
|
||||
}
|
||||
|
||||
.st24 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
|
||||
.st25 {
|
||||
fill: #594ec0;
|
||||
}
|
||||
|
||||
.st26 {
|
||||
fill: #9487e7;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="945.85" y1="1577.02" x2="761.14" y2="1748.14" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7367dc"/>
|
||||
<stop offset="1" stop-color="#4e44b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient1" x1="722.68" y1="1595.53" x2="596.26" y2="1731.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#5b53c3"/>
|
||||
<stop offset="1" stop-color="#392e91"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient2" x1="948.55" y1="1362.8" x2="635.29" y2="1790.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#887ded"/>
|
||||
<stop offset="1" stop-color="#342a89"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon class="st20" points="681.31 447.54 681.31 467.16 481.77 437.1 483.48 417.01 681.31 447.54"/>
|
||||
<path class="st18" d="M555.67,521.86c4.67-.95,15.46,7.01,23.68,7.48,10.63.6,19.08-6.88,26.17-5.47,5.58,1.11,5.41,7.21,2.27,10.95-11.39,13.58-43.92,11.42-55.13-1.69-3.59-4.2-2.96-10.06,3-11.27Z"/>
|
||||
<circle class="st16" cx="528.3" cy="506.02" r="13.52"/>
|
||||
<circle class="st22" cx="632.44" cy="508.64" r="13.43"/>
|
||||
<g>
|
||||
<path class="st24" d="M712.92,596.88v110.09l-234.36,18.53c-1.16-49.03-1.16-98.08,0-147.15.23-.82-.31-2.2.05-3.19,21.9,1.33,43.96,3.31,65.89,5.38,7.24.68,14.5,1.49,21.8,2.18,48.86,4.63,97.74,9.75,146.62,14.16Z"/>
|
||||
<g>
|
||||
<path class="st3" d="M712.92,706.97v-110.09,110.09Z"/>
|
||||
<path class="st4" d="M684.58,612.14c1.03,24.3,1.03,48.64,0,73.03-.26,1.25.34,2.95-.05,4.3-7.86,1.68-15.92,1.09-23.93,1.16v-81.75c4.86,1.32,21.04.41,23.98,3.27Z"/>
|
||||
<path class="st17" d="M531.97,596.88v68.67c-.15.54-.33,1.07-.6,1.55-1.78,3.15-25.19-1.98-24.47-9.18v-62.13c2.91-2.93,19.92,2.34,25.07,1.09Z"/>
|
||||
<path class="st13" d="M583.21,602.33c1.01,21.29,1.14,42.65.39,64.08l-1.56.27c-6.01-4.73-13.02-8.71-20.97-7.04l-1.85-1.71v-57.77c.85-.85,22.56,1.51,23.98,2.18Z"/>
|
||||
<path class="st10" d="M634.44,606.69v52.32l-2.45.71c-5.32-4.68-12.12-9.52-19.58-7.78l-1.95-1.65v-45.78l23.98,2.18Z"/>
|
||||
<path class="st8" d="M634.44,659.01v32.7l-23.98,1.09v-42.51c8.78-3.35,17.83,2.97,23.98,8.72Z"/>
|
||||
<path class="st6" d="M572.31,694.98l-13.63.35.55-37.41c7.76-2.78,18.95,1.52,23.97,7.63v-63.22c4.08,1.92-.83,79.47,1.1,89.93-.76,4.34-8.2,2.64-11.99,2.72Z"/>
|
||||
<path class="st15" d="M531.97,665.55c.59,10.18.59,20.36,0,30.52-.66,2.02-12.84.95-15.85,1.04-2.38.07-8.37,2.53-9.22.05v-39.24c5.92,5.49,17.26,8.87,25.07,7.63Z"/>
|
||||
<path class="st12" d="M559.23,657.92c.05,12.34-.04,24.73,0,37.07,4.35-.07,8.74.09,13.09,0l-14.18,1.1,1.1-95.93c.06,19.24-.08,38.53,0,57.77Z"/>
|
||||
<path class="st12" d="M506.9,657.92c.05,13.06-.04,26.18,0,39.24v-101.37c.06,20.69-.08,41.44,0,62.13Z"/>
|
||||
<path class="st12" d="M531.97,696.07c1.19-3.63-.03-24.65,0-30.52.14-22.87-.1-45.8,0-68.67v99.19Z"/>
|
||||
<path class="st3" d="M610.46,650.29c.05,14.15-.04,28.36,0,42.51v-88.29c.04,15.24-.05,30.54,0,45.78Z"/>
|
||||
<path class="st3" d="M634.44,691.71c.02-10.88-.03-21.82,0-32.7.05-17.42-.03-34.9,0-52.32v85.02Z"/>
|
||||
<path class="st3" d="M684.58,685.17v-73.03c.86.84,1.32,1.19,1.15,2.61-2.14,21.8,3.15,49.6-1.15,70.42Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="st5" points="453.49 725.5 306.33 705.88 306.28 601.87 453.49 573.99 453.49 725.5"/>
|
||||
<g>
|
||||
<path class="st23" d="M408.8,608.87c2.53-2.96,13.34-2.52,17.43-4.36.24,7.3.9,14.48,1.1,21.8l-.66,68.12-8.06-.54c-2.16.07-4.37.04-6.54,0l-3.11-.93c-.93-27.55-1.62-56.31-.16-84.09Z"/>
|
||||
<path class="st0" d="M387,611.05v80.66c-3.62-2.19-15.34.22-16.96-2.11l-.33-74.59c.96-2.88,13.95-2.4,17.29-3.96Z"/>
|
||||
<polygon class="st7" points="348.85 617.59 348.85 688.44 332.5 687.35 332.5 619.77 348.85 617.59"/>
|
||||
<path class="st11" d="M408.8,608.87l-.09,82.37,3.36,2.65c-1.45-.03-2.92.04-4.37,0,1.53-10.81-2.44-80.9,1.1-85.03Z"/>
|
||||
<path class="st11" d="M427.33,626.31c.63,22.84-.47,45.83,0,68.68l-8.73-1.09c2.53-.08,5.1.06,7.64,0l1.08-67.59Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M336.86,517.31c.08-12.89-1.57-31.04-.06-43.12.15-1.2-.08-2.43,1.14-3.22l129.72-55.03,1.08,123.49,39.25,13.84c-16.1,1.65-32.37,1.74-48.49,3.29-54.98,5.3-109.8,17.26-163.87,27.97-2.52-.29-5.64,5.91-5.64,7.44v106.28c-44.32-.62-86.36-25.78-106.3-65.38-37.11-73.67,8.88-159.45,89.48-171.08,5.05-70.54,77.25-114.81,143.6-93.75,29.07-38.59,75.03-64,123.39-68.73,87.07-8.53,171.01,51.08,190.76,136.23,1.53,6.58,5.31,22.98,4.99,28.91-.31,5.95-8.43,6.69-11,11.42,76.86-14.03,142.15,54.55,125.94,130.85-11.63,54.71-59.12,92.08-115.04,91.52l-1.09-119.9-68.67-12c.38-2.1,2.28-1.8,3.76-2.22,3.68-1.06,27.53-4.67,27.91-6.07l-.04-116.53c-.1-3.45-2.21-6.47-5.46-7.62l-217.45-34.36c-50.26,17.71-99.5,38.5-149.08,58.09-3.38,1.55-4.24,4.11-5.18,7.35-1.1,30.52-1.1,61.04,0,91.56,1,1.09.36,1.02,2.17.66,1.57-.31,12.26-3.46,13.09-3.93,1.12-11.83,1.48-23.82,1.09-35.97Z"/>
|
||||
<g>
|
||||
<path class="st1" d="M735.81,698.25l-1.09-119.9,1.09,119.9Z"/>
|
||||
<path class="st25" d="M320.51,556.55c-.76-.83-1.38-1.13-1.15-2.62,2.09-27.37-2.57-58.33-.02-85.26.21-2.19.83-2.53,1.17-3.69v91.56Z"/>
|
||||
<path class="st14" d="M441.5,456.26v85.02c-2.84,1.69-12.57,1.65-16.35,2.18v-81.21c3.7-2.31,9.24-5.16,13.35-6.37,1.09-.32,2.59-1.31,3,.37Z"/>
|
||||
<path class="st26" d="M404.44,470.43v76.3c-5.3,1.35-10.21.87-15.26,3.27v-75.21c5.46-.27,9.78-4.76,15.26-4.36Z"/>
|
||||
<path class="st19" d="M370.65,552.19c-3.11,1.45-9.5.64-13.08,2.18v-66.49c0-.61,10.85-4.28,11.46-4.36,2.82-.39,1.19,1.72,1.62,3.27,1.04,21.85,1.04,43.65,0,65.4Z"/>
|
||||
<path class="st9" d="M441.5,541.29v-85.02,85.02Z"/>
|
||||
<path class="st21" d="M370.65,552.19v-65.4c4.6,16.83-1.22,44.53,1.15,62.76-.16,1.29.36,1.94-1.15,2.64Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
+7
-3
@@ -33,7 +33,7 @@ services:
|
||||
- "traefik.http.routers.traefik.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.traefik.service=api@internal"
|
||||
- "traefik.http.routers.traefik.middlewares=traefik-auth"
|
||||
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH:-admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/}"
|
||||
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH}"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "ping"]
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:15-alpine
|
||||
container_name: containr-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-containr}
|
||||
@@ -83,6 +83,8 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: containr-backend
|
||||
ports:
|
||||
- "8081:8080" # Temporary direct access
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379
|
||||
@@ -91,7 +93,7 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production}
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:3000}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For Docker API access
|
||||
# /var/run/docker.sock:/var/run/docker.sock:ro # Commented out to prevent permission issues
|
||||
# Development volumes (only mounted in dev mode)
|
||||
- ${DEV_MODE:-./internal}:/app/internal
|
||||
networks:
|
||||
@@ -120,6 +122,8 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: containr-frontend
|
||||
ports:
|
||||
- "3000:80" # Temporary direct access
|
||||
environment:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://api.localhost}
|
||||
- VITE_ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
|
||||
@@ -36,6 +36,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
|
||||
@@ -70,6 +70,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
UserEmail string `json:"user_email" db:"user_email"`
|
||||
Resource string `json:"resource" db:"resource"`
|
||||
ResourceID string `json:"resource_id" db:"resource_id"`
|
||||
Action string `json:"action" db:"action"`
|
||||
Details string `json:"details" db:"details"`
|
||||
IPAddress string `json:"ip_address" db:"ip_address"`
|
||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
type AuditLogDetail struct {
|
||||
OldValue interface{} `json:"old_value,omitempty"`
|
||||
NewValue interface{} `json:"new_value,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
auditID, userID, resource, resourceID, action, string(detailsJSON), time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userEmail, _ := c.Get("user_email")
|
||||
|
||||
details["ip_address"] = c.ClientIP()
|
||||
details["user_agent"] = c.GetHeader("User-Agent")
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, user_email, resource, resource_id, action, details, ip_address, user_agent, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
auditID, userID, userEmail, resource, resourceID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
var auditDB *database.DB
|
||||
|
||||
func GetAuditDB() *database.DB {
|
||||
return auditDB
|
||||
}
|
||||
|
||||
func SetAuditDB(db *database.DB) {
|
||||
auditDB = db
|
||||
}
|
||||
|
||||
func handleGetAuditLogs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
|
||||
resource := c.Query("resource")
|
||||
action := c.Query("action")
|
||||
page := c.DefaultQuery("page", "1")
|
||||
limit := c.DefaultQuery("limit", "50")
|
||||
|
||||
query := `SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
|
||||
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
|
||||
FROM audit_logs WHERE user_id = $1`
|
||||
args := []interface{}{userID}
|
||||
argNum := 2
|
||||
|
||||
if resource != "" {
|
||||
query += " AND resource = $" + string(rune('0'+argNum))
|
||||
args = append(args, resource)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if action != "" {
|
||||
query += " AND action = $" + string(rune('0'+argNum))
|
||||
args = append(args, action)
|
||||
argNum++
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT $" + string(rune('0'+argNum)) + " OFFSET $" + string(rune('0'+argNum+1))
|
||||
args = append(args, limit, (atoi(page)-1)*atoi(limit))
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
}
|
||||
|
||||
func handleGetResourceAuditLogs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
resource := c.Param("resource")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
|
||||
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE user_id = $1 AND resource = $2 AND resource_id = $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
userID, resource, resourceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
var result int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
result = result*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CronJob struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ProjectID string `json:"project_id" db:"project_id"`
|
||||
ServiceID string `json:"service_id" db:"service_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Schedule string `json:"schedule" db:"schedule"`
|
||||
Command string `json:"command" db:"command"`
|
||||
Timezone string `json:"timezone" db:"timezone"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at" db:"last_run_at"`
|
||||
NextRunAt *time.Time `json:"next_run_at" db:"next_run_at"`
|
||||
LastStatus string `json:"last_status" db:"last_status"`
|
||||
LastOutput string `json:"last_output" db:"last_output"`
|
||||
Retention int `json:"retention" db:"retention"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CronExecution struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
CronJobID string `json:"cron_job_id" db:"cron_job_id"`
|
||||
StartedAt time.Time `json:"started_at" db:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Output string `json:"output" db:"output"`
|
||||
Error string `json:"error" db:"error"`
|
||||
}
|
||||
|
||||
type CreateCronJobRequest struct {
|
||||
ProjectID string `json:"project_id" binding:"required"`
|
||||
ServiceID string `json:"service_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Schedule string `json:"schedule" binding:"required"`
|
||||
Command string `json:"command" binding:"required"`
|
||||
Timezone string `json:"timezone"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
type UpdateCronJobRequest struct {
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Timezone string `json:"timezone"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
func handleGetCronJobs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
projectID := c.Query("project_id")
|
||||
|
||||
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE p.owner_id = $1`
|
||||
args := []interface{}{userID}
|
||||
|
||||
if projectID != "" {
|
||||
query += " AND cj.project_id = $2"
|
||||
args = append(args, projectID)
|
||||
}
|
||||
|
||||
query += " ORDER BY cj.created_at DESC"
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch cron jobs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []CronJob
|
||||
for rows.Next() {
|
||||
var job CronJob
|
||||
err := rows.Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cron_jobs": jobs})
|
||||
}
|
||||
|
||||
func handleCreateCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
var req CreateCronJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM projects p
|
||||
JOIN services s ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
req.ServiceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Timezone == "" {
|
||||
req.Timezone = "UTC"
|
||||
}
|
||||
if req.Retention == 0 {
|
||||
req.Retention = 30
|
||||
}
|
||||
|
||||
nextRun := calculateNextRun(req.Schedule, req.Timezone)
|
||||
|
||||
job := CronJob{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
Name: req.Name,
|
||||
Schedule: req.Schedule,
|
||||
Command: req.Command,
|
||||
Timezone: req.Timezone,
|
||||
Enabled: req.Enabled,
|
||||
NextRunAt: nextRun,
|
||||
Retention: req.Retention,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", job.ID, "create", map[string]interface{}{
|
||||
"name": job.Name,
|
||||
"schedule": job.Schedule,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"cron_job": job})
|
||||
}
|
||||
|
||||
func handleGetCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at, p.owner_id
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cron_job": job})
|
||||
}
|
||||
|
||||
func handleUpdateCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var req UpdateCronJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.Schedule != "" {
|
||||
updates["schedule"] = req.Schedule
|
||||
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
|
||||
}
|
||||
if req.Command != "" {
|
||||
updates["command"] = req.Command
|
||||
}
|
||||
if req.Timezone != "" {
|
||||
updates["timezone"] = req.Timezone
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.Retention > 0 {
|
||||
updates["retention"] = req.Retention
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
updateQuery := "UPDATE cron_jobs SET "
|
||||
args := []interface{}{}
|
||||
argNum := 1
|
||||
for key, value := range updates {
|
||||
if argNum > 1 {
|
||||
updateQuery += ", "
|
||||
}
|
||||
updateQuery += key + " = $" + string(rune('0'+argNum))
|
||||
args = append(args, value)
|
||||
argNum++
|
||||
}
|
||||
updateQuery += " WHERE id = $" + string(rune('0'+argNum))
|
||||
args = append(args, jobID)
|
||||
|
||||
_, err = db.Exec(updateQuery, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "update", updates)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cron job updated successfully"})
|
||||
}
|
||||
|
||||
func handleDeleteCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "delete", nil)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted successfully"})
|
||||
}
|
||||
|
||||
func handleGetCronExecutions(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
|
||||
FROM cron_executions
|
||||
WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 100`,
|
||||
jobID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var executions []CronExecution
|
||||
for rows.Next() {
|
||||
var exec CronExecution
|
||||
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
executions = append(executions, exec)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"executions": executions})
|
||||
}
|
||||
|
||||
func handleTriggerCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.command, p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.Command, &ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
execID := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
execID, jobID, now, "running",
|
||||
)
|
||||
|
||||
go executeCronJob(jobID, execID, job.Command)
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
||||
"execution_id": execID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Cron job triggered",
|
||||
"execution_id": execID,
|
||||
})
|
||||
}
|
||||
|
||||
func calculateNextRun(schedule, timezone string) *time.Time {
|
||||
now := time.Now()
|
||||
next := now.Add(1 * time.Hour)
|
||||
return &next
|
||||
}
|
||||
|
||||
func executeCronJob(jobID, execID, command string) {
|
||||
db := auditDB
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
now := time.Now()
|
||||
db.Exec(
|
||||
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
|
||||
now, "success", "Job completed successfully", execID,
|
||||
)
|
||||
|
||||
db.Exec(
|
||||
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
|
||||
now, "success", time.Now().Add(1*time.Hour), jobID,
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
cronJobsData, _ := json.Marshal([]CronJob{})
|
||||
_ = cronJobsData
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DeploymentModel struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
||||
CommitHash *string `json:"commit_hash" db:"commit_hash"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ImageName string `json:"image_name" db:"image_name"`
|
||||
ImageTag string `json:"image_tag" db:"image_tag"`
|
||||
BuildLog string `json:"build_log" db:"build_log"`
|
||||
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
|
||||
Error *string `json:"error" db:"error"`
|
||||
StartedAt *time.Time `json:"started_at" db:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateDeploymentRequest struct {
|
||||
CommitHash string `json:"commit_hash"`
|
||||
Branch string `json:"branch"`
|
||||
Trigger string `json:"trigger"`
|
||||
EnvVars map[string]string `json:"env_vars"`
|
||||
}
|
||||
|
||||
type DeploymentResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
CommitHash *string `json:"commit_hash"`
|
||||
Status string `json:"status"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func handleGetDeployments(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDStr := c.Param("id")
|
||||
serviceID, err := uuid.Parse(serviceIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deployments []DeploymentModel
|
||||
for rows.Next() {
|
||||
var d DeploymentModel
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||
return
|
||||
}
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDStr := c.Param("id")
|
||||
serviceID, err := uuid.Parse(serviceIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateDeploymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var service Service
|
||||
var projectOwner string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if projectOwner != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
d := DeploymentModel{
|
||||
ID: uuid.New(),
|
||||
ServiceID: serviceID,
|
||||
CommitHash: &req.CommitHash,
|
||||
Status: "pending",
|
||||
ImageName: "",
|
||||
ImageTag: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if req.CommitHash != "" {
|
||||
d.CommitHash = &req.CommitHash
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
engine, exists := c.Get("deployment_engine")
|
||||
if exists && engine != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
envVarsJSON, _ := json.Marshal(req.EnvVars)
|
||||
_ = envVarsJSON
|
||||
|
||||
deployReq := &deployment.DeploymentRequest{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: serviceID.String(),
|
||||
Environment: service.Environment,
|
||||
Config: deployment.ServiceConfig{
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Environment: req.EnvVars,
|
||||
Replicas: 1,
|
||||
},
|
||||
BuildConfig: &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: service.BuildPath,
|
||||
Branch: service.GitBranch,
|
||||
Commit: req.CommitHash,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
Source: "api",
|
||||
User: userID.(string),
|
||||
Timestamp: now,
|
||||
},
|
||||
}
|
||||
|
||||
_, _ = engine.(*deployment.DeploymentEngine).Deploy(ctx, deployReq)
|
||||
}()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||
ID: d.ID,
|
||||
ServiceID: d.ServiceID,
|
||||
CommitHash: d.CommitHash,
|
||||
Status: d.Status,
|
||||
CreatedAt: d.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var d DeploymentModel
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployment": d})
|
||||
}
|
||||
|
||||
func handleRollbackDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var targetDeployment DeploymentModel
|
||||
var serviceID uuid.UUID
|
||||
var ownerCheck string
|
||||
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
|
||||
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
|
||||
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
|
||||
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
|
||||
&ownerCheck,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rollbackID := uuid.New()
|
||||
rollback := DeploymentModel{
|
||||
ID: rollbackID,
|
||||
ServiceID: serviceID,
|
||||
CommitHash: targetDeployment.CommitHash,
|
||||
Status: "rolling_back",
|
||||
ImageName: targetDeployment.ImageName,
|
||||
ImageTag: targetDeployment.ImageTag,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
|
||||
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), rollbackID,
|
||||
)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"deployment": DeploymentResponse{
|
||||
ID: rollback.ID,
|
||||
ServiceID: rollback.ServiceID,
|
||||
CommitHash: rollback.CommitHash,
|
||||
Status: rollback.Status,
|
||||
ImageName: rollback.ImageName,
|
||||
ImageTag: rollback.ImageTag,
|
||||
CreatedAt: rollback.CreatedAt,
|
||||
},
|
||||
"message": "Rollback initiated",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
|
||||
func handleGetLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDStr := c.Param("id")
|
||||
serviceID, err := uuid.Parse(serviceIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
follow := c.DefaultQuery("follow", "false") == "true"
|
||||
tail := c.DefaultQuery("tail", "100")
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": []LogEntry{}, "message": "Docker not available - showing mock logs"})
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
Follow: follow,
|
||||
Tail: tail,
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{
|
||||
{Timestamp: time.Now(), Message: "Service not running or container not found", Stream: "system"},
|
||||
{Timestamp: time.Now(), Message: "Start the service to see logs", Stream: "system"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
defer logsReader.Close()
|
||||
|
||||
if follow {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
streamWriter := c.Writer
|
||||
flusher, ok := streamWriter.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"})
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleanLine := stripDockerLogHeader(line)
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: cleanLine,
|
||||
Stream: "stdout",
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
|
||||
entry.Stream = "stderr"
|
||||
}
|
||||
|
||||
fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n",
|
||||
entry.Timestamp.Format(time.RFC3339),
|
||||
strings.ReplaceAll(entry.Message, `"`, `\"`),
|
||||
entry.Stream,
|
||||
)
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logBytes, err := io.ReadAll(logsReader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"})
|
||||
return
|
||||
}
|
||||
|
||||
logContent := string(logBytes)
|
||||
var logEntries []LogEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(logContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleanLine := stripDockerLogHeader(line)
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: cleanLine,
|
||||
Stream: "stdout",
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
|
||||
entry.Stream = "stderr"
|
||||
}
|
||||
logEntries = append(logEntries, entry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var buildLog, runtimeLog string
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.build_log, d.runtime_log, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(&buildLog, &runtimeLog, &ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
logType := c.DefaultQuery("type", "all")
|
||||
var logs []LogEntry
|
||||
|
||||
parseLogs := func(logContent string, stream string) []LogEntry {
|
||||
var entries []LogEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(logContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: line,
|
||||
Stream: stream,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
if logType == "all" || logType == "build" {
|
||||
logs = append(logs, parseLogs(buildLog, "build")...)
|
||||
}
|
||||
if logType == "all" || logType == "runtime" {
|
||||
logs = append(logs, parseLogs(runtimeLog, "runtime")...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"build_log": buildLog,
|
||||
"runtime_log": runtimeLog,
|
||||
})
|
||||
}
|
||||
|
||||
func stripDockerLogHeader(line string) string {
|
||||
if len(line) > 8 && (line[0] == 1 || line[0] == 2) {
|
||||
return line[8:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
+53
-55
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
@@ -17,14 +19,17 @@ import (
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Initialize Docker client
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
panic("Failed to initialize Docker client: " + err.Error())
|
||||
}
|
||||
// Initialize Docker client (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
buildManager := &build.BuildManager{} // Default empty manager
|
||||
|
||||
// Initialize build manager
|
||||
buildManager := build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
if client, err := docker.NewClient(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize Docker client: %v", err)
|
||||
log.Printf("Docker-related features will be disabled")
|
||||
} else {
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
}
|
||||
|
||||
// Initialize build handler
|
||||
buildHandler := NewBuildHandler(buildManager, dockerClient)
|
||||
@@ -116,29 +121,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
protected.POST("/projects", handleCreateProject)
|
||||
|
||||
// Service routes (nested under projects)
|
||||
protected.GET("/projects/:id/services", handleGetServices)
|
||||
protected.POST("/projects/:id/services", handleCreateService)
|
||||
|
||||
// Generic project routes
|
||||
protected.GET("/projects/:id", handleGetProject)
|
||||
protected.PUT("/projects/:id", handleUpdateProject)
|
||||
protected.DELETE("/projects/:id", handleDeleteProject)
|
||||
|
||||
// Service routes
|
||||
protected.GET("/projects/:project_id/services", handleGetServices)
|
||||
protected.POST("/projects/:project_id/services", handleCreateService)
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:service_id/deployments", handleGetDeployments)
|
||||
protected.POST("/services/:service_id/deployments", handleCreateDeployment)
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
||||
protected.GET("/deployments/:id", handleGetDeployment)
|
||||
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||
|
||||
// Environment variables routes
|
||||
protected.GET("/services/:service_id/variables", handleGetVariables)
|
||||
protected.PUT("/services/:service_id/variables", handleUpdateVariables)
|
||||
protected.GET("/services/:id/variables", handleGetVariables)
|
||||
protected.PUT("/services/:id/variables", handleUpdateVariables)
|
||||
|
||||
// Logs routes
|
||||
protected.GET("/services/:service_id/logs", handleGetLogs)
|
||||
protected.GET("/services/:id/logs", handleGetLogs)
|
||||
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
||||
|
||||
// Git integration routes
|
||||
@@ -176,8 +185,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
agentHandler.SetupRoutes(api)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:project_id/preview-environments", handleGetPreviewEnvironments)
|
||||
protected.POST("/projects/:project_id/preview-environments", handleCreatePreviewEnvironment)
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
|
||||
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
|
||||
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
|
||||
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
|
||||
@@ -186,48 +195,37 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
|
||||
// Security routes
|
||||
protected.POST("/security/scans", securityHandler.StartSecurityScan)
|
||||
protected.GET("/security/scans/:scanId", securityHandler.GetSecurityScan)
|
||||
protected.GET("/projects/:projectId/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
protected.GET("/projects/:projectId/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
protected.PUT("/vulnerabilities/:vulnId", securityHandler.UpdateVulnerability)
|
||||
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
|
||||
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
|
||||
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
|
||||
protected.GET("/security/compliance/reports/:reportId", securityHandler.GetComplianceReport)
|
||||
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
|
||||
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
|
||||
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
|
||||
protected.GET("/projects/:projectId/security/metrics", securityHandler.GetSecurityMetrics)
|
||||
protected.GET("/projects/:projectId/security/audit-logs", securityHandler.GetAuditLogs)
|
||||
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
|
||||
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
|
||||
|
||||
// WebSocket endpoint
|
||||
protected.GET("/ws", handleWebSocket)
|
||||
|
||||
// Templates routes
|
||||
protected.GET("/templates", handleGetTemplates)
|
||||
protected.GET("/templates/:id", handleGetTemplate)
|
||||
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
|
||||
// Cron Jobs routes
|
||||
protected.GET("/cron-jobs", handleGetCronJobs)
|
||||
protected.POST("/cron-jobs", handleCreateCronJob)
|
||||
protected.GET("/cron-jobs/:id", handleGetCronJob)
|
||||
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
|
||||
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
||||
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
|
||||
|
||||
// Audit Logs routes
|
||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetDeployments(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleGetDeployment(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleRollbackDeployment(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleGetVariables(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleUpdateVariables(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleGetLogs(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
c.JSON(501, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ServiceTemplate struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Category string `json:"category" db:"category"`
|
||||
Logo string `json:"logo" db:"logo"`
|
||||
Config string `json:"config" db:"config"`
|
||||
Variables string `json:"variables" db:"variables"`
|
||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type TemplateConfig struct {
|
||||
Type string `json:"type"`
|
||||
Runtime string `json:"runtime"`
|
||||
BuildCommand string `json:"build_command"`
|
||||
StartCommand string `json:"start_command"`
|
||||
Port int `json:"port"`
|
||||
HealthCheck string `json:"health_check"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Dockerfile string `json:"dockerfile,omitempty"`
|
||||
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateVariable struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Default string `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func handleGetTemplates(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
category := c.Query("category")
|
||||
|
||||
query := "SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates"
|
||||
args := []interface{}{}
|
||||
|
||||
if category != "" {
|
||||
query += " WHERE category = $1"
|
||||
args = append(args, category)
|
||||
}
|
||||
query += " ORDER BY is_official DESC, name ASC"
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var templates []ServiceTemplate
|
||||
for rows.Next() {
|
||||
var t ServiceTemplate
|
||||
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"templates": templates})
|
||||
}
|
||||
|
||||
func handleGetTemplate(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
templateID := c.Param("id")
|
||||
|
||||
var t ServiceTemplate
|
||||
err := db.QueryRow(
|
||||
"SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates WHERE id = $1",
|
||||
templateID,
|
||||
).Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var config TemplateConfig
|
||||
if err := json.Unmarshal([]byte(t.Config), &config); err == nil {
|
||||
}
|
||||
|
||||
var variables []TemplateVariable
|
||||
if err := json.Unmarshal([]byte(t.Variables), &variables); err == nil {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"template": t,
|
||||
"config": config,
|
||||
"variables": variables,
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateFromTemplate(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
templateID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
ProjectID string `json:"project_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var template ServiceTemplate
|
||||
err := db.QueryRow(
|
||||
"SELECT id, name, description, category, logo, config, variables, is_official FROM service_templates WHERE id = $1",
|
||||
templateID,
|
||||
).Scan(&template.ID, &template.Name, &template.Description, &template.Category, &template.Logo, &template.Config, &template.Variables, &template.IsOfficial)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var config TemplateConfig
|
||||
json.Unmarshal([]byte(template.Config), &config)
|
||||
|
||||
var templateVars []TemplateVariable
|
||||
json.Unmarshal([]byte(template.Variables), &templateVars)
|
||||
|
||||
envVars := make(map[string]string)
|
||||
for key, value := range config.Environment {
|
||||
envVars[key] = value
|
||||
}
|
||||
for key, value := range req.Variables {
|
||||
envVars[key] = value
|
||||
}
|
||||
|
||||
envVarsJSON, _ := json.Marshal(envVars)
|
||||
|
||||
serviceID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
serviceID, req.ProjectID, req.Name, config.Type, "stopped", config.Runtime, config.StartCommand,
|
||||
string(envVarsJSON), "0.5", "512Mi", now, now,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "service", serviceID.String(), "create", map[string]interface{}{
|
||||
"template_id": templateID,
|
||||
"name": req.Name,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"service_id": serviceID.String(),
|
||||
"message": "Service created from template",
|
||||
})
|
||||
}
|
||||
|
||||
func SeedTemplates() []ServiceTemplate {
|
||||
templates := []ServiceTemplate{
|
||||
{
|
||||
ID: "tpl-nodejs",
|
||||
Name: "Node.js Application",
|
||||
Description: "Generic Node.js application with automatic dependency detection",
|
||||
Category: "web",
|
||||
Logo: "https://cdn.simpleicons.org/node.js",
|
||||
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npm start","port":3000,"health_check":"/health"}`,
|
||||
Variables: `[{"key":"NODE_ENV","label":"Node Environment","default":"production","required":false,"secret":false},{"key":"NPM_TOKEN","label":"NPM Token","default":"","required":false,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-react",
|
||||
Name: "React Application",
|
||||
Description: "React single-page application with Vite",
|
||||
Category: "frontend",
|
||||
Logo: "https://cdn.simpleicons.org/react",
|
||||
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npx serve -s dist","port":3000}`,
|
||||
Variables: `[{"key":"VITE_API_URL","label":"API URL","default":"","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-python",
|
||||
Name: "Python Application",
|
||||
Description: "Python application with FastAPI/Flask support",
|
||||
Category: "web",
|
||||
Logo: "https://cdn.simpleicons.org/python",
|
||||
Config: `{"type":"web","runtime":"python","build_command":"pip install -r requirements.txt","start_command":"python main.py","port":8000}`,
|
||||
Variables: `[{"key":"PYTHON_VERSION","label":"Python Version","default":"3.11","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-go",
|
||||
Name: "Go Application",
|
||||
Description: "Go backend service",
|
||||
Category: "web",
|
||||
Logo: "https://cdn.simpleicons.org/go",
|
||||
Config: `{"type":"web","runtime":"go","build_command":"go build -o app .","start_command":"./app","port":8080}`,
|
||||
Variables: `[{"key":"GO_VERSION","label":"Go Version","default":"1.21","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-postgres",
|
||||
Name: "PostgreSQL Database",
|
||||
Description: "Managed PostgreSQL database",
|
||||
Category: "database",
|
||||
Logo: "https://cdn.simpleicons.org/postgresql",
|
||||
Config: `{"type":"database","runtime":"postgres","port":5432}`,
|
||||
Variables: `[{"key":"POSTGRES_USER","label":"Username","default":"postgres","required":true,"secret":false},{"key":"POSTGRES_PASSWORD","label":"Password","default":"","required":true,"secret":true},{"key":"POSTGRES_DB","label":"Database Name","default":"app","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-redis",
|
||||
Name: "Redis Cache",
|
||||
Description: "In-memory data store",
|
||||
Category: "database",
|
||||
Logo: "https://cdn.simpleicons.org/redis",
|
||||
Config: `{"type":"database","runtime":"redis","port":6379}`,
|
||||
Variables: `[{"key":"REDIS_PASSWORD","label":"Password","default":"","required":false,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-mongodb",
|
||||
Name: "MongoDB Database",
|
||||
Description: "NoSQL document database",
|
||||
Category: "database",
|
||||
Logo: "https://cdn.simpleicons.org/mongodb",
|
||||
Config: `{"type":"database","runtime":"mongodb","port":27017}`,
|
||||
Variables: `[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Root Username","default":"admin","required":true,"secret":false},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-worker",
|
||||
Name: "Background Worker",
|
||||
Description: "Background job processing service",
|
||||
Category: "worker",
|
||||
Logo: "https://cdn.simpleicons.org/terminal",
|
||||
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
|
||||
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-cron",
|
||||
Name: "Cron Job",
|
||||
Description: "Scheduled task runner",
|
||||
Category: "cron",
|
||||
Logo: "https://cdn.simpleicons.org/clock",
|
||||
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
|
||||
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-docker",
|
||||
Name: "Docker Image",
|
||||
Description: "Deploy from any Docker image",
|
||||
Category: "custom",
|
||||
Logo: "https://cdn.simpleicons.org/docker",
|
||||
Config: `{"type":"web","runtime":"docker","port":80}`,
|
||||
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
}
|
||||
return templates
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type EnvironmentVariable struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
||||
Key string `json:"key" db:"key"`
|
||||
Value string `json:"value" db:"value"`
|
||||
IsSecret bool `json:"is_secret" db:"is_secret"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateVariablesRequest struct {
|
||||
Variables []VariableInput `json:"variables" binding:"required"`
|
||||
}
|
||||
|
||||
type VariableInput struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value"`
|
||||
IsSecret bool `json:"is_secret"`
|
||||
}
|
||||
|
||||
func handleGetVariables(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDStr := c.Param("id")
|
||||
serviceID, err := uuid.Parse(serviceIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
|
||||
FROM environment_variables
|
||||
WHERE service_id = $1
|
||||
ORDER BY key ASC`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var variables []EnvironmentVariable
|
||||
for rows.Next() {
|
||||
var v EnvironmentVariable
|
||||
err := rows.Scan(
|
||||
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
||||
return
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = "********"
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
||||
}
|
||||
|
||||
func handleUpdateVariables(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDStr := c.Param("id")
|
||||
serviceID, err := uuid.Parse(serviceIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateVariablesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT p.owner_id FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.(*database.DB).Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, v := range req.Variables {
|
||||
varID := uuid.New()
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
|
||||
FROM environment_variables
|
||||
WHERE service_id = $1
|
||||
ORDER BY key ASC`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var variables []EnvironmentVariable
|
||||
for rows.Next() {
|
||||
var v EnvironmentVariable
|
||||
err := rows.Scan(
|
||||
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v.IsSecret {
|
||||
v.Value = "********"
|
||||
}
|
||||
variables = append(variables, v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type WebSocketClient struct {
|
||||
ID string
|
||||
UserID string
|
||||
Conn *websocket.Conn
|
||||
Channels map[string]bool
|
||||
Send chan []byte
|
||||
}
|
||||
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
Data interface{} `json:"data"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type WebSocketHub struct {
|
||||
clients map[string]*WebSocketClient
|
||||
broadcast chan *WebSocketMessage
|
||||
register chan *WebSocketClient
|
||||
unregister chan *WebSocketClient
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var wsHub = &WebSocketHub{
|
||||
clients: make(map[string]*WebSocketClient),
|
||||
broadcast: make(chan *WebSocketMessage, 100),
|
||||
register: make(chan *WebSocketClient),
|
||||
unregister: make(chan *WebSocketClient),
|
||||
}
|
||||
|
||||
func init() {
|
||||
go wsHub.run()
|
||||
}
|
||||
|
||||
func (h *WebSocketHub) run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client.ID] = client
|
||||
h.mu.Unlock()
|
||||
log.Printf("WebSocket client connected: %s", client.ID)
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client.ID]; ok {
|
||||
delete(h.clients, client.ID)
|
||||
close(client.Send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("WebSocket client disconnected: %s", client.ID)
|
||||
|
||||
case message := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling WebSocket message: %v", err)
|
||||
h.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, client := range h.clients {
|
||||
if client.Channels[message.Channel] || message.Channel == "all" {
|
||||
select {
|
||||
case client.Send <- data:
|
||||
default:
|
||||
close(client.Send)
|
||||
delete(h.clients, client.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebSocketHub) Broadcast(channel string, msgType string, data interface{}) {
|
||||
message := &WebSocketMessage{
|
||||
Type: msgType,
|
||||
Channel: channel,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
h.broadcast <- message
|
||||
}
|
||||
|
||||
func (h *WebSocketHub) BroadcastToUser(userID string, msgType string, data interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
message := &WebSocketMessage{
|
||||
Type: msgType,
|
||||
Channel: "user:" + userID,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
messageBytes, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, client := range h.clients {
|
||||
if client.UserID == userID {
|
||||
select {
|
||||
case client.Send <- messageBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleWebSocket(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &WebSocketClient{
|
||||
ID: generateClientID(),
|
||||
UserID: userID.(string),
|
||||
Conn: conn,
|
||||
Channels: make(map[string]bool),
|
||||
Send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
wsHub.register <- client
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (c *WebSocketClient) readPump() {
|
||||
defer func() {
|
||||
wsHub.unregister <- c
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
c.Conn.SetReadLimit(512)
|
||||
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
for {
|
||||
_, message, err := c.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
Action string `json:"action"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Action {
|
||||
case "subscribe":
|
||||
c.Channels[msg.Channel] = true
|
||||
case "unsubscribe":
|
||||
delete(c.Channels, msg.Channel)
|
||||
}
|
||||
|
||||
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WebSocketClient) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.Send:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
w, err := c.Conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(message)
|
||||
|
||||
n := len(c.Send)
|
||||
for i := 0; i < n; i++ {
|
||||
w.Write([]byte{'\n'})
|
||||
w.Write(<-c.Send)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateClientID() string {
|
||||
return time.Now().Format("20060102150405") + "-" + randomString(8)
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[time.Now().Nanosecond()%len(letters)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func BroadcastServiceUpdate(serviceID string, data interface{}) {
|
||||
wsHub.Broadcast("service:"+serviceID, "service_update", data)
|
||||
}
|
||||
|
||||
func BroadcastDeploymentUpdate(deploymentID string, data interface{}) {
|
||||
wsHub.Broadcast("deployment:"+deploymentID, "deployment_update", data)
|
||||
}
|
||||
|
||||
func BroadcastBuildUpdate(buildID string, data interface{}) {
|
||||
wsHub.Broadcast("build:"+buildID, "build_update", data)
|
||||
}
|
||||
|
||||
func BroadcastMetricsUpdate(serviceID string, data interface{}) {
|
||||
wsHub.Broadcast("metrics:"+serviceID, "metrics_update", data)
|
||||
}
|
||||
|
||||
func BroadcastScalingEvent(serviceID string, data interface{}) {
|
||||
wsHub.Broadcast("scaling:"+serviceID, "scaling_event", data)
|
||||
}
|
||||
|
||||
func NotifyUser(userID string, notificationType string, data interface{}) {
|
||||
wsHub.BroadcastToUser(userID, notificationType, data)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package networking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TraefikConfig struct {
|
||||
ConfigDir string
|
||||
AcmeEmail string
|
||||
AcmeCAServer string
|
||||
EntryPoint string
|
||||
CertResolver string
|
||||
DomainSuffix string
|
||||
}
|
||||
|
||||
type TraefikRouter struct {
|
||||
Name string `json:"name"`
|
||||
Rule string `json:"rule"`
|
||||
Service string `json:"service"`
|
||||
EntryPoint string `json:"entryPoints"`
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
TLS *TLSConfig `json:"tls,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
type TraefikService struct {
|
||||
Name string `json:"name"`
|
||||
LoadBalancer *LoadBalancerConfig `json:"loadBalancer"`
|
||||
Weighted *WeightedConfig `json:"weighted,omitempty"`
|
||||
Mirroring *MirroringConfig `json:"mirroring,omitempty"`
|
||||
}
|
||||
|
||||
type LoadBalancerConfig struct {
|
||||
Servers []ServerConfig `json:"servers"`
|
||||
HealthCheck *HealthCheck `json:"healthCheck,omitempty"`
|
||||
Sticky *StickyConfig `json:"sticky,omitempty"`
|
||||
PassHostHeader bool `json:"passHostHeader"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
URL string `json:"url"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
type HealthCheck struct {
|
||||
Path string `json:"path"`
|
||||
Interval string `json:"interval"`
|
||||
Timeout string `json:"timeout"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
FollowRedirects bool `json:"followRedirects,omitempty"`
|
||||
}
|
||||
|
||||
type StickyConfig struct {
|
||||
Cookie *CookieConfig `json:"cookie,omitempty"`
|
||||
}
|
||||
|
||||
type CookieConfig struct {
|
||||
Name string `json:"name"`
|
||||
Secure bool `json:"secure"`
|
||||
HTTPOnly bool `json:"httpOnly"`
|
||||
SameSite string `json:"sameSite,omitempty"`
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
CertResolver string `json:"certResolver,omitempty"`
|
||||
Domains []Domain `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
Main string `json:"main"`
|
||||
SANS []string `json:"sans,omitempty"`
|
||||
}
|
||||
|
||||
type WeightedConfig struct {
|
||||
Services []WeightedService `json:"services"`
|
||||
}
|
||||
|
||||
type WeightedService struct {
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
type MirroringConfig struct {
|
||||
MainService string `json:"mainService"`
|
||||
Mirrors []MirrorService `json:"mirrors"`
|
||||
}
|
||||
|
||||
type MirrorService struct {
|
||||
Name string `json:"name"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
type TraefikMiddleware struct {
|
||||
Name string `json:"name"`
|
||||
RateLimit *RateLimitConfig `json:"rateLimit,omitempty"`
|
||||
StripPrefix *StripPrefixConfig `json:"stripPrefix,omitempty"`
|
||||
AddPrefix *AddPrefixConfig `json:"addPrefix,omitempty"`
|
||||
Headers *HeadersConfig `json:"headers,omitempty"`
|
||||
RedirectRegex *RedirectRegexConfig `json:"redirectRegex,omitempty"`
|
||||
RedirectScheme *RedirectSchemeConfig `json:"redirectScheme,omitempty"`
|
||||
Compress *CompressConfig `json:"compress,omitempty"`
|
||||
Auth *AuthConfig `json:"basicAuth,omitempty"`
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Average int64 `json:"average"`
|
||||
Burst int64 `json:"burst"`
|
||||
Period time.Duration `json:"period"`
|
||||
SourceCriterion *SourceCriterion `json:"sourceCriterion,omitempty"`
|
||||
}
|
||||
|
||||
type SourceCriterion struct {
|
||||
IPStrategy *IPStrategy `json:"ipStrategy,omitempty"`
|
||||
}
|
||||
|
||||
type IPStrategy struct {
|
||||
Depth int `json:"depth"`
|
||||
ExcludedIPs []string `json:"excludedIPs,omitempty"`
|
||||
}
|
||||
|
||||
type StripPrefixConfig struct {
|
||||
Prefixes []string `json:"prefixes"`
|
||||
}
|
||||
|
||||
type AddPrefixConfig struct {
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type HeadersConfig struct {
|
||||
CustomRequestHeaders map[string]string `json:"customRequestHeaders,omitempty"`
|
||||
CustomResponseHeaders map[string]string `json:"customResponseHeaders,omitempty"`
|
||||
AccessControlAllowMethods []string `json:"accessControlAllowMethods,omitempty"`
|
||||
AccessControlAllowHeaders []string `json:"accessControlAllowHeaders,omitempty"`
|
||||
AccessControlAllowOriginList []string `json:"accessControlAllowOriginList,omitempty"`
|
||||
SSLRedirect bool `json:"sslRedirect,omitempty"`
|
||||
SSLProxyHeaders map[string]string `json:"sslProxyHeaders,omitempty"`
|
||||
}
|
||||
|
||||
type RedirectRegexConfig struct {
|
||||
Regex string `json:"regex"`
|
||||
Replacement string `json:"replacement"`
|
||||
Permanent bool `json:"permanent"`
|
||||
}
|
||||
|
||||
type RedirectSchemeConfig struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Permanent bool `json:"permanent"`
|
||||
}
|
||||
|
||||
type CompressConfig struct {
|
||||
MinResponseBodyBytes int `json:"minResponseBodyBytes"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Users []string `json:"users"`
|
||||
UsersFile string `json:"usersFile,omitempty"`
|
||||
}
|
||||
|
||||
type TraefikManager struct {
|
||||
config *TraefikConfig
|
||||
sd *ServiceDiscovery
|
||||
routers map[string]*TraefikRouter
|
||||
services map[string]*TraefikService
|
||||
middlewares map[string]*TraefikMiddleware
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewTraefikManager(config *TraefikConfig, sd *ServiceDiscovery) *TraefikManager {
|
||||
if config.EntryPoint == "" {
|
||||
config.EntryPoint = "websecure"
|
||||
}
|
||||
if config.CertResolver == "" {
|
||||
config.CertResolver = "letsencrypt"
|
||||
}
|
||||
if config.DomainSuffix == "" {
|
||||
config.DomainSuffix = "containr.local"
|
||||
}
|
||||
|
||||
if config.ConfigDir != "" {
|
||||
os.MkdirAll(config.ConfigDir, 0755)
|
||||
}
|
||||
|
||||
return &TraefikManager{
|
||||
config: config,
|
||||
sd: sd,
|
||||
routers: make(map[string]*TraefikRouter),
|
||||
services: make(map[string]*TraefikService),
|
||||
middlewares: make(map[string]*TraefikMiddleware),
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceRouteConfig struct {
|
||||
ServiceName string
|
||||
ProjectID string
|
||||
Port int
|
||||
Domain string
|
||||
PathPrefix string
|
||||
EnableTLS bool
|
||||
EnableAuth bool
|
||||
AuthUsers []string
|
||||
RateLimit *RateLimitConfig
|
||||
HealthPath string
|
||||
StickySession bool
|
||||
Priority int
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) CreateServiceRoute(ctx context.Context, config *ServiceRouteConfig) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
serviceName := fmt.Sprintf("%s-%s", config.ProjectID, config.ServiceName)
|
||||
routerName := fmt.Sprintf("%s-router", serviceName)
|
||||
|
||||
if config.Domain == "" {
|
||||
config.Domain = fmt.Sprintf("%s.%s", serviceName, tm.config.DomainSuffix)
|
||||
}
|
||||
|
||||
var servers []ServerConfig
|
||||
if tm.sd != nil {
|
||||
instances, err := tm.sd.DiscoverService(ctx, config.ServiceName, config.ProjectID)
|
||||
if err == nil {
|
||||
for _, instance := range instances {
|
||||
servers = append(servers, ServerConfig{
|
||||
URL: fmt.Sprintf("http://%s:%d", instance.IPAddress, config.Port),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
servers = append(servers, ServerConfig{
|
||||
URL: fmt.Sprintf("http://%s:%d", serviceName, config.Port),
|
||||
})
|
||||
}
|
||||
|
||||
lbConfig := &LoadBalancerConfig{
|
||||
Servers: servers,
|
||||
PassHostHeader: true,
|
||||
}
|
||||
|
||||
if config.HealthPath != "" {
|
||||
lbConfig.HealthCheck = &HealthCheck{
|
||||
Path: config.HealthPath,
|
||||
Interval: "30s",
|
||||
Timeout: "5s",
|
||||
}
|
||||
}
|
||||
|
||||
if config.StickySession {
|
||||
lbConfig.Sticky = &StickyConfig{
|
||||
Cookie: &CookieConfig{
|
||||
Name: fmt.Sprintf("%s_sticky", serviceName),
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: "None",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
service := &TraefikService{
|
||||
Name: serviceName,
|
||||
LoadBalancer: lbConfig,
|
||||
}
|
||||
tm.services[serviceName] = service
|
||||
|
||||
rule := fmt.Sprintf("Host(`%s`)", config.Domain)
|
||||
if config.PathPrefix != "" {
|
||||
rule = fmt.Sprintf("%s && PathPrefix(`%s`)", rule, config.PathPrefix)
|
||||
}
|
||||
|
||||
router := &TraefikRouter{
|
||||
Name: routerName,
|
||||
Rule: rule,
|
||||
Service: serviceName,
|
||||
EntryPoint: tm.config.EntryPoint,
|
||||
Priority: config.Priority,
|
||||
}
|
||||
|
||||
var middlewares []string
|
||||
|
||||
if config.RateLimit != nil {
|
||||
mwName := fmt.Sprintf("%s-ratelimit", serviceName)
|
||||
tm.middlewares[mwName] = &TraefikMiddleware{
|
||||
Name: mwName,
|
||||
RateLimit: config.RateLimit,
|
||||
}
|
||||
middlewares = append(middlewares, mwName)
|
||||
}
|
||||
|
||||
if config.EnableAuth && len(config.AuthUsers) > 0 {
|
||||
mwName := fmt.Sprintf("%s-auth", serviceName)
|
||||
tm.middlewares[mwName] = &TraefikMiddleware{
|
||||
Name: "auth",
|
||||
Auth: &AuthConfig{
|
||||
Users: config.AuthUsers,
|
||||
},
|
||||
}
|
||||
middlewares = append(middlewares, mwName)
|
||||
}
|
||||
|
||||
if len(middlewares) > 0 {
|
||||
router.Middlewares = middlewares
|
||||
}
|
||||
|
||||
if config.EnableTLS {
|
||||
router.TLS = &TLSConfig{
|
||||
CertResolver: tm.config.CertResolver,
|
||||
Domains: []Domain{
|
||||
{Main: config.Domain},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tm.routers[routerName] = router
|
||||
|
||||
if tm.config.ConfigDir != "" {
|
||||
if err := tm.writeDynamicConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Created Traefik route for service %s at %s", serviceName, config.Domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) RemoveServiceRoute(ctx context.Context, serviceName, projectID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
serviceKey := fmt.Sprintf("%s-%s", projectID, serviceName)
|
||||
routerName := fmt.Sprintf("%s-router", serviceKey)
|
||||
|
||||
delete(tm.services, serviceKey)
|
||||
delete(tm.routers, routerName)
|
||||
|
||||
delete(tm.middlewares, fmt.Sprintf("%s-ratelimit", serviceKey))
|
||||
delete(tm.middlewares, fmt.Sprintf("%s-auth", serviceKey))
|
||||
|
||||
if tm.config.ConfigDir != "" {
|
||||
if err := tm.writeDynamicConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Removed Traefik route for service %s", serviceKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) UpdateServiceServers(ctx context.Context, serviceName, projectID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
serviceKey := fmt.Sprintf("%s-%s", projectID, serviceName)
|
||||
service, exists := tm.services[serviceKey]
|
||||
if !exists {
|
||||
return fmt.Errorf("service not found: %s", serviceKey)
|
||||
}
|
||||
|
||||
if tm.sd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
instances, err := tm.sd.DiscoverService(ctx, serviceName, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var servers []ServerConfig
|
||||
for _, instance := range instances {
|
||||
servers = append(servers, ServerConfig{
|
||||
URL: fmt.Sprintf("http://%s:%d", instance.IPAddress, instance.Port),
|
||||
})
|
||||
}
|
||||
|
||||
if len(servers) > 0 {
|
||||
service.LoadBalancer.Servers = servers
|
||||
}
|
||||
|
||||
if tm.config.ConfigDir != "" {
|
||||
if err := tm.writeDynamicConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) writeDynamicConfig() error {
|
||||
configPath := filepath.Join(tm.config.ConfigDir, "dynamic.yaml")
|
||||
|
||||
config := map[string]interface{}{
|
||||
"http": map[string]interface{}{
|
||||
"routers": tm.routers,
|
||||
"services": tm.services,
|
||||
"middlewares": tm.middlewares,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ioutil.WriteFile(configPath, data, 0644)
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) GetRoutes() []*TraefikRouter {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
routes := make([]*TraefikRouter, 0, len(tm.routers))
|
||||
for _, router := range tm.routers {
|
||||
routes = append(routes, router)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) GetServices() []*TraefikService {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
services := make([]*TraefikService, 0, len(tm.services))
|
||||
for _, service := range tm.services {
|
||||
services = append(services, service)
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
func (tm *TraefikManager) GenerateDomain(serviceName, projectID string) string {
|
||||
return fmt.Sprintf("%s-%s.%s", projectID, serviceName, tm.config.DomainSuffix)
|
||||
}
|
||||
@@ -122,21 +122,36 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Add update triggers to tables with updated_at columns
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON environment_variables
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_deployments_updated_at BEFORE UPDATE ON deployments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Add update triggers to tables with updated_at columns (only if they don't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_users_updated_at') THEN
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_projects_updated_at') THEN
|
||||
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_environments_updated_at') THEN
|
||||
CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_services_updated_at') THEN
|
||||
CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_environment_variables_updated_at') THEN
|
||||
CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON environment_variables
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_deployments_updated_at') THEN
|
||||
CREATE TRIGGER update_deployments_updated_at BEFORE UPDATE ON deployments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
@@ -92,21 +92,34 @@ CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_webhook_id ON git_deploym
|
||||
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_service_id ON git_deployment_triggers(service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_branch ON git_deployment_triggers(branch);
|
||||
|
||||
-- Add update triggers to new tables
|
||||
CREATE TRIGGER update_git_providers_updated_at BEFORE UPDATE ON git_providers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_git_repositories_updated_at BEFORE UPDATE ON git_repositories
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_git_webhooks_updated_at BEFORE UPDATE ON git_webhooks
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_git_branches_updated_at BEFORE UPDATE ON git_branches
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_git_deployment_triggers_updated_at BEFORE UPDATE ON git_deployment_triggers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Add update triggers to new tables (only if they don't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_providers_updated_at') THEN
|
||||
CREATE TRIGGER update_git_providers_updated_at BEFORE UPDATE ON git_providers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_repositories_updated_at') THEN
|
||||
CREATE TRIGGER update_git_repositories_updated_at BEFORE UPDATE ON git_repositories
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_webhooks_updated_at') THEN
|
||||
CREATE TRIGGER update_git_webhooks_updated_at BEFORE UPDATE ON git_webhooks
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_branches_updated_at') THEN
|
||||
CREATE TRIGGER update_git_branches_updated_at BEFORE UPDATE ON git_branches
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_git_deployment_triggers_updated_at') THEN
|
||||
CREATE TRIGGER update_git_deployment_triggers_updated_at BEFORE UPDATE ON git_deployment_triggers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add foreign key constraint for project_members table if not exists
|
||||
DO $$
|
||||
|
||||
@@ -141,21 +141,34 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER update_node_agents_updated_at BEFORE UPDATE ON node_agents
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_container_instances_updated_at BEFORE UPDATE ON container_instances
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_agent_commands_updated_at BEFORE UPDATE ON agent_commands
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_node_clusters_updated_at BEFORE UPDATE ON node_clusters
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_scheduling_rules_updated_at BEFORE UPDATE ON scheduling_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Create triggers for updated_at (only if they don't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_node_agents_updated_at') THEN
|
||||
CREATE TRIGGER update_node_agents_updated_at BEFORE UPDATE ON node_agents
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_container_instances_updated_at') THEN
|
||||
CREATE TRIGGER update_container_instances_updated_at BEFORE UPDATE ON container_instances
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_agent_commands_updated_at') THEN
|
||||
CREATE TRIGGER update_agent_commands_updated_at BEFORE UPDATE ON agent_commands
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_node_clusters_updated_at') THEN
|
||||
CREATE TRIGGER update_node_clusters_updated_at BEFORE UPDATE ON node_clusters
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_scheduling_rules_updated_at') THEN
|
||||
CREATE TRIGGER update_scheduling_rules_updated_at BEFORE UPDATE ON scheduling_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Insert default cluster
|
||||
INSERT INTO node_clusters (id, name, description, status, total_resources, used_resources)
|
||||
|
||||
@@ -266,11 +266,25 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at columns
|
||||
CREATE TRIGGER update_service_discovery_updated_at BEFORE UPDATE ON service_discovery FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_dns_records_updated_at BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_metrics_aggregation_rules_updated_at BEFORE UPDATE ON metrics_aggregation_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_alert_rules_updated_at BEFORE UPDATE ON alert_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Create triggers for updated_at columns (only if they don't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_service_discovery_updated_at') THEN
|
||||
CREATE TRIGGER update_service_discovery_updated_at BEFORE UPDATE ON service_discovery FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_dns_records_updated_at') THEN
|
||||
CREATE TRIGGER update_dns_records_updated_at BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_metrics_aggregation_rules_updated_at') THEN
|
||||
CREATE TRIGGER update_metrics_aggregation_rules_updated_at BEFORE UPDATE ON metrics_aggregation_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_alert_rules_updated_at') THEN
|
||||
CREATE TRIGGER update_alert_rules_updated_at BEFORE UPDATE ON alert_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Insert default aggregation rules
|
||||
INSERT INTO metrics_aggregation_rules (name, metric_type, aggregation_function, interval, fields) VALUES
|
||||
|
||||
@@ -85,10 +85,15 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_database_services_updated_at
|
||||
BEFORE UPDATE ON database_services
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_database_services_updated_at') THEN
|
||||
CREATE TRIGGER update_database_services_updated_at
|
||||
BEFORE UPDATE ON database_services
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Insert default settings for existing databases (if any)
|
||||
INSERT INTO database_settings (database_id)
|
||||
|
||||
@@ -181,14 +181,37 @@ BEGIN
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER update_security_scans_updated_at BEFORE UPDATE ON security_scans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_vulnerabilities_updated_at BEFORE UPDATE ON vulnerabilities FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_compliance_frameworks_updated_at BEFORE UPDATE ON compliance_frameworks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_compliance_controls_updated_at BEFORE UPDATE ON compliance_controls FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_compliance_reports_updated_at BEFORE UPDATE ON compliance_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_compliance_risks_updated_at BEFORE UPDATE ON compliance_risks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_data_retention_policies_updated_at BEFORE UPDATE ON data_retention_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Create triggers for updated_at (only if they don't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_security_scans_updated_at') THEN
|
||||
CREATE TRIGGER update_security_scans_updated_at BEFORE UPDATE ON security_scans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_vulnerabilities_updated_at') THEN
|
||||
CREATE TRIGGER update_vulnerabilities_updated_at BEFORE UPDATE ON vulnerabilities FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_frameworks_updated_at') THEN
|
||||
CREATE TRIGGER update_compliance_frameworks_updated_at BEFORE UPDATE ON compliance_frameworks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_controls_updated_at') THEN
|
||||
CREATE TRIGGER update_compliance_controls_updated_at BEFORE UPDATE ON compliance_controls FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_reports_updated_at') THEN
|
||||
CREATE TRIGGER update_compliance_reports_updated_at BEFORE UPDATE ON compliance_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_compliance_risks_updated_at') THEN
|
||||
CREATE TRIGGER update_compliance_risks_updated_at BEFORE UPDATE ON compliance_risks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_data_retention_policies_updated_at') THEN
|
||||
CREATE TRIGGER update_data_retention_policies_updated_at BEFORE UPDATE ON data_retention_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Row Level Security (RLS) for audit logs
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Add missing columns to deployments table
|
||||
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS image_name VARCHAR(500);
|
||||
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS image_tag VARCHAR(100);
|
||||
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS runtime_log TEXT;
|
||||
ALTER TABLE deployments ADD COLUMN IF NOT EXISTS error TEXT;
|
||||
|
||||
-- Add missing columns to services table for compatibility
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS type VARCHAR(50);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS status VARCHAR(50);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS image VARCHAR(500);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS command TEXT;
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS environment VARCHAR(50);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS git_repo VARCHAR(500);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS git_branch VARCHAR(100);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS build_path VARCHAR(500);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu VARCHAR(50);
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory VARCHAR(50);
|
||||
|
||||
-- Update existing records to have default values
|
||||
UPDATE services SET type = service_type WHERE type IS NULL;
|
||||
UPDATE services SET status = 'stopped' WHERE status IS NULL;
|
||||
UPDATE services SET environment = 'production' WHERE environment IS NULL;
|
||||
UPDATE services SET cpu = '0.5' WHERE cpu IS NULL;
|
||||
UPDATE services SET memory = '512Mi' WHERE memory IS NULL;
|
||||
|
||||
-- Add index for new columns
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_image_name ON deployments(image_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Service Templates
|
||||
CREATE TABLE IF NOT EXISTS service_templates (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
logo VARCHAR(500),
|
||||
config JSONB NOT NULL,
|
||||
variables JSONB DEFAULT '[]',
|
||||
is_official BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Cron Jobs
|
||||
CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
schedule VARCHAR(100) NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
last_run_at TIMESTAMP WITH TIME ZONE,
|
||||
next_run_at TIMESTAMP WITH TIME ZONE,
|
||||
last_status VARCHAR(50),
|
||||
last_output TEXT,
|
||||
retention INTEGER DEFAULT 30,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Cron Executions
|
||||
CREATE TABLE IF NOT EXISTS cron_executions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cron_job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
finished_at TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
output TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Audit Logs
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
user_email VARCHAR(255),
|
||||
resource VARCHAR(50) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_service_templates_category ON service_templates(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_jobs_project_id ON cron_jobs(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_jobs_service_id ON cron_jobs(service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run_at) WHERE enabled = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_executions_job_id ON cron_executions(cron_job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
|
||||
-- Insert default templates
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
|
||||
VALUES
|
||||
('tpl-nodejs', 'Node.js Application', 'Generic Node.js application with automatic dependency detection', 'web', 'https://cdn.simpleicons.org/node.js', '{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npm start","port":3000}', '[{"key":"NODE_ENV","label":"Node Environment","default":"production"}]', true),
|
||||
('tpl-react', 'React Application', 'React single-page application with Vite', 'frontend', 'https://cdn.simpleicons.org/react', '{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npx serve -s dist","port":3000}', '[{"key":"VITE_API_URL","label":"API URL"}]', true),
|
||||
('tpl-python', 'Python Application', 'Python application with FastAPI/Flask support', 'web', 'https://cdn.simpleicons.org/python', '{"type":"web","runtime":"python","build_command":"pip install -r requirements.txt","start_command":"python main.py","port":8000}', '[{"key":"PYTHON_VERSION","label":"Python Version","default":"3.11"}]', true),
|
||||
('tpl-go', 'Go Application', 'Go backend service', 'web', 'https://cdn.simpleicons.org/go', '{"type":"web","runtime":"go","build_command":"go build -o app .","start_command":"./app","port":8080}', '[]', true),
|
||||
('tpl-postgres', 'PostgreSQL Database', 'Managed PostgreSQL database', 'database', 'https://cdn.simpleicons.org/postgresql', '{"type":"database","runtime":"postgres","port":5432}', '[{"key":"POSTGRES_USER","label":"Username"},{"key":"POSTGRES_PASSWORD","label":"Password","secret":true}]', true),
|
||||
('tpl-redis', 'Redis Cache', 'In-memory data store', 'database', 'https://cdn.simpleicons.org/redis', '{"type":"database","runtime":"redis","port":6379}', '[{"key":"REDIS_PASSWORD","label":"Password","secret":true}]', true),
|
||||
('tpl-mongodb', 'MongoDB Database', 'NoSQL document database', 'database', 'https://cdn.simpleicons.org/mongodb', '{"type":"database","runtime":"mongodb","port":27017}', '[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Username"},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Password","secret":true}]', true),
|
||||
('tpl-worker', 'Background Worker', 'Background job processing service', 'worker', 'https://cdn.simpleicons.org/terminal', '{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}', '[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4"}]', true),
|
||||
('tpl-docker', 'Docker Image', 'Deploy from any Docker image', 'custom', 'https://cdn.simpleicons.org/docker', '{"type":"web","runtime":"docker","port":80}', '[{"key":"IMAGE","label":"Docker Image","required":true}]', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Triggers for updated_at
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_cron_jobs_updated_at') THEN
|
||||
CREATE TRIGGER update_cron_jobs_updated_at BEFORE UPDATE ON cron_jobs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
Generated
+1522
-1
File diff suppressed because it is too large
Load Diff
+19
-2
@@ -8,15 +8,23 @@
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.6",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -25,6 +33,7 @@
|
||||
"@radix-ui/react-switch": "^1.1.6",
|
||||
"@radix-ui/react-tabs": "^1.1.6",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
@@ -40,6 +49,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.58.0",
|
||||
"react-resizable-panels": "^4.6.4",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.7.0",
|
||||
@@ -52,16 +62,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^17.6.3",
|
||||
"terser": "^5.46.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"assessments": {
|
||||
"naming_quality": 82,
|
||||
"error_consistency": 68,
|
||||
"abstraction_fitness": 75,
|
||||
"logic_clarity": 88,
|
||||
"ai_generated_debt": 72,
|
||||
"type_safety": 70,
|
||||
"contract_coherence": 74
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"file": "src/lib/api.ts",
|
||||
"dimension": "type_safety",
|
||||
"line": 95,
|
||||
"confidence": "high",
|
||||
"message": "Return type `any` for user object — define a proper User type matching the auth response (e.g., `{ token: string; user: { id: string; email: string; name: string } }`)."
|
||||
},
|
||||
{
|
||||
"file": "src/lib/api.ts",
|
||||
"dimension": "abstraction_fitness",
|
||||
"line": 6,
|
||||
"confidence": "high",
|
||||
"message": "The `api` object (lines 5-67) duplicates token/auth header logic in all 4 methods, yet `apiCall()` (line 70) already implements this pattern — remove the `api` object or refactor it to use `apiCall` internally."
|
||||
},
|
||||
{
|
||||
"file": "src/lib/api.ts",
|
||||
"dimension": "type_safety",
|
||||
"line": 111,
|
||||
"confidence": "high",
|
||||
"message": "`getProfile` returns `apiCall<any>` — should return a typed `User` from @/types."
|
||||
},
|
||||
{
|
||||
"file": "src/lib/api.ts",
|
||||
"dimension": "type_safety",
|
||||
"line": 133,
|
||||
"confidence": "medium",
|
||||
"message": "`pagination: any` in getProjects response — define `PaginationMeta { page: number; limit: number; total: number; pages: number }`."
|
||||
},
|
||||
{
|
||||
"file": "src/pages/Settings.tsx",
|
||||
"dimension": "error_consistency",
|
||||
"line": 80,
|
||||
"confidence": "high",
|
||||
"message": "Catch block only logs error without propagating or setting error state — either rethrow, set an error state, or show user feedback via toast."
|
||||
},
|
||||
{
|
||||
"file": "src/pages/Settings.tsx",
|
||||
"dimension": "error_consistency",
|
||||
"line": 101,
|
||||
"confidence": "high",
|
||||
"message": "Catch block swallows error silently — add toast notification or error state to inform user of fetch failure."
|
||||
},
|
||||
{
|
||||
"file": "src/components/security/SecurityDashboard.tsx",
|
||||
"dimension": "error_consistency",
|
||||
"line": 108,
|
||||
"confidence": "high",
|
||||
"message": "Catch block only `console.error` — the UI shows stale/empty data with no indication of failure. Set an error state or show an error toast."
|
||||
},
|
||||
{
|
||||
"file": "src/components/security/SecurityDashboard.tsx",
|
||||
"dimension": "error_consistency",
|
||||
"line": 132,
|
||||
"confidence": "high",
|
||||
"message": "Catch block swallows scan start failure — user has no feedback that the scan didn't initiate. Add toast or error state."
|
||||
},
|
||||
{
|
||||
"file": "src/hooks/useAuth.tsx",
|
||||
"dimension": "type_safety",
|
||||
"line": 6,
|
||||
"confidence": "high",
|
||||
"message": "`user: any | null` should be typed as `User | null` using the User type from @/types."
|
||||
},
|
||||
{
|
||||
"file": "src/components/git/DeploymentTriggers.tsx",
|
||||
"dimension": "ai_generated_debt",
|
||||
"line": 64,
|
||||
"confidence": "high",
|
||||
"message": "Comment `// TODO: Replace with actual API call` indicates placeholder code — implement real API integration or extract to a proper mock service for development."
|
||||
},
|
||||
{
|
||||
"file": "src/components/git/DeploymentTriggers.tsx",
|
||||
"dimension": "ai_generated_debt",
|
||||
"line": 106,
|
||||
"confidence": "medium",
|
||||
"message": "Another `// TODO: Replace with actual API call` — these TODOs should be tracked in issue tracker, not committed as code comments."
|
||||
},
|
||||
{
|
||||
"file": "src/components/dashboard/ProjectCanvas.tsx",
|
||||
"dimension": "naming_quality",
|
||||
"line": 342,
|
||||
"confidence": "medium",
|
||||
"message": "Function `getActionButton` returns JSX, not just a button — rename to `renderActionButtons` or `getServiceActionButtons` for clarity."
|
||||
},
|
||||
{
|
||||
"file": "src/components/database/DatabaseDetailPanel.tsx",
|
||||
"dimension": "naming_quality",
|
||||
"line": 148,
|
||||
"confidence": "low",
|
||||
"message": "Parameter `_action` is prefixed with underscore to indicate unused, but the action is still destructured — either use it or remove from the parameter entirely if the mutation doesn't need it."
|
||||
},
|
||||
{
|
||||
"file": "src/pages/Scaling.tsx",
|
||||
"dimension": "ai_generated_debt",
|
||||
"line": 49,
|
||||
"confidence": "medium",
|
||||
"message": "Mock API object `scalingApi` with inline mock data (lines 49-167) should be moved to a separate mock/test utilities file for cleaner production code."
|
||||
},
|
||||
{
|
||||
"file": "src/pages/Projects.tsx",
|
||||
"dimension": "logic_clarity",
|
||||
"line": 128,
|
||||
"confidence": "medium",
|
||||
"message": "Type assertion `as ProjectWithStats[]` after `useMemo` — the API returns `Project[]` but stats are expected. Either add stats to the API response type or document the transformation."
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare function App(): import("react/jsx-runtime").JSX.Element;
|
||||
export default App;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Projects from './pages/Projects';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import Analytics from './pages/Analytics';
|
||||
import GitIntegration from './pages/GitIntegration';
|
||||
import Infrastructure from './pages/Infrastructure';
|
||||
import NodeAgents from './pages/NodeAgents';
|
||||
import DatabaseServices from './pages/DatabaseServices';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
function LoadingScreen() {
|
||||
return (_jsxs("div", { className: "min-h-screen flex flex-col items-center justify-center bg-background", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-12 h-12 border-2 border-primary border-t-transparent rounded-full animate-spin" }), _jsx("div", { className: "absolute inset-0 w-12 h-12 border-2 border-primary/20 rounded-full" })] }), _jsx("p", { className: "mt-4 text-sm text-muted-foreground animate-pulse", children: "Loading..." })] }));
|
||||
}
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) {
|
||||
return _jsx(LoadingScreen, {});
|
||||
}
|
||||
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: !isAuthenticated ? _jsx(Login, {}) : _jsx(Navigate, { to: "/" }) }), _jsxs(Route, { path: "/", element: isAuthenticated ? _jsx(Layout, {}) : _jsx(Navigate, { to: "/login" }), children: [_jsx(Route, { index: true, element: _jsx(Dashboard, {}) }), _jsx(Route, { path: "projects", element: _jsx(Projects, {}) }), _jsx(Route, { path: "projects/:projectId", element: _jsx(ProjectDetail, {}) }), _jsx(Route, { path: "analytics", element: _jsx(Analytics, {}) }), _jsx(Route, { path: "git", element: _jsx(GitIntegration, {}) }), _jsx(Route, { path: "infrastructure", element: _jsx(Infrastructure, {}) }), _jsx(Route, { path: "agents", element: _jsx(NodeAgents, {}) }), _jsx(Route, { path: "databases", element: _jsx(DatabaseServices, {}) }), _jsx(Route, { path: "settings", element: _jsx(Settings, {}) })] })] }));
|
||||
}
|
||||
function App() {
|
||||
return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(ThemeProvider, { children: _jsx(AuthProvider, { children: _jsx(Toaster, { children: _jsx(Router, { children: _jsx(AppContent, {}) }) }) }) }) }));
|
||||
}
|
||||
export default App;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
|
||||
vi.mock('./hooks/useAuth', () => ({
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./lib/api', () => ({
|
||||
authApi: {
|
||||
getProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
|
||||
const createMockAuth = (overrides = {}) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
describe('LoadingScreen', () => {
|
||||
it('shows loading screen when auth is loading', () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
|
||||
|
||||
render(<App />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppContent routing', () => {
|
||||
it('renders without crashing when not authenticated', async () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth());
|
||||
|
||||
render(<App />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders without crashing when authenticated', async () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth({
|
||||
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
|
||||
render(<App />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryClient configuration', () => {
|
||||
it('creates QueryClient with correct defaults', () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
|
||||
|
||||
render(<App />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider structure', () => {
|
||||
it('wraps app in all required providers', () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth({ isLoading: true }));
|
||||
|
||||
const { container } = render(<App />, { wrapper: createWrapper() });
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-13
@@ -1,6 +1,8 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Projects from './pages/Projects';
|
||||
@@ -13,15 +15,34 @@ import DatabaseServices from './pages/DatabaseServices';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="absolute inset-0 w-12 h-12 border-2 border-primary/20 rounded-full" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground animate-pulse">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -37,7 +58,6 @@ function AppContent() {
|
||||
<Route path="agents" element={<NodeAgents />} />
|
||||
<Route path="databases" element={<DatabaseServices />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
{/* Add more routes here as we create them */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
@@ -45,13 +65,17 @@ function AppContent() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Toaster>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
</Toaster>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import React, { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import type { Node as ReactFlowNode } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import ServiceNodeComponent from './nodes/ServiceNode';
|
||||
import EmptyCanvasNode from './nodes/EmptyCanvasNode';
|
||||
import AnimatedEdge from './edges/AnimatedEdge';
|
||||
import CanvasContextMenu from './CanvasContextMenu';
|
||||
|
||||
const nodeTypes = {
|
||||
service: ServiceNodeComponent,
|
||||
empty: EmptyCanvasNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
animated: AnimatedEdge,
|
||||
};
|
||||
|
||||
function CanvasContent() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isOverUI, setIsOverUI] = useState(false);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
onConnect,
|
||||
setSelectedNode,
|
||||
} = useCanvasStore();
|
||||
|
||||
const [internalNodes, setInternalNodes, onNodesChange] = useNodesState(nodes);
|
||||
const [internalEdges, setInternalEdges, onEdgesChange] = useEdgesState(edges);
|
||||
|
||||
// Sync internal state with store
|
||||
React.useEffect(() => {
|
||||
setInternalNodes(nodes);
|
||||
}, [nodes, setInternalNodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInternalEdges(edges);
|
||||
}, [edges, setInternalEdges]);
|
||||
|
||||
// Global hover detection for UI elements
|
||||
useEffect(() => {
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
// Check if hovering over any UI element that should disable canvas interactions
|
||||
if (target && target.closest && (
|
||||
target.closest('[data-ui-element="true"]') ||
|
||||
target.closest('.react-flow__node') ||
|
||||
target.closest('[role="menu"]') ||
|
||||
target.closest('[role="dialog"]') ||
|
||||
target.closest('[data-cmdk-list]'))) {
|
||||
setIsOverUI(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
// Check if leaving UI elements
|
||||
if (target && target.closest && (
|
||||
target.closest('[data-ui-element="true"]') ||
|
||||
target.closest('.react-flow__node') ||
|
||||
target.closest('[role="menu"]') ||
|
||||
target.closest('[role="dialog"]') ||
|
||||
target.closest('[data-cmdk-list]'))) {
|
||||
setIsOverUI(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
// Check if currently over any UI element
|
||||
if (target && target.closest) {
|
||||
const overUI = target.closest('[data-ui-element="true"]') ||
|
||||
target.closest('.react-flow__node') ||
|
||||
target.closest('[role="menu"]') ||
|
||||
target.closest('[role="dialog"]') ||
|
||||
target.closest('[data-cmdk-list]');
|
||||
setIsOverUI(!!overUI);
|
||||
// Debug logging
|
||||
if (overUI) {
|
||||
console.log('Over UI element:', overUI);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent wheel events at document level when over UI
|
||||
const handleDocumentWheel = (e: WheelEvent) => {
|
||||
if (isOverUI) {
|
||||
// When hovering over UI elements, prevent canvas zoom/scroll
|
||||
// regardless of what the wheel event target is
|
||||
console.log('Preventing canvas wheel event while over UI');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseenter', handleMouseEnter, true);
|
||||
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove, true);
|
||||
document.addEventListener('wheel', handleDocumentWheel, { passive: false, capture: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseenter', handleMouseEnter, true);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove, true);
|
||||
document.removeEventListener('wheel', handleDocumentWheel, { capture: true } as any);
|
||||
};
|
||||
}, [isOverUI]);
|
||||
|
||||
// Ensure container has proper dimensions
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current && containerRef.current.parentElement) {
|
||||
const parent = containerRef.current.parentElement;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
|
||||
// Only set dimensions if they're valid and different from current
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
const currentWidth = containerRef.current.style.width;
|
||||
const currentHeight = containerRef.current.style.height;
|
||||
const newWidth = `${rect.width}px`;
|
||||
const newHeight = `${rect.height}px`;
|
||||
|
||||
if (currentWidth !== newWidth || currentHeight !== newHeight) {
|
||||
containerRef.current.style.width = newWidth;
|
||||
containerRef.current.style.height = newHeight;
|
||||
}
|
||||
} else {
|
||||
// Fallback dimensions if parent has no size
|
||||
containerRef.current.style.width = '100vw';
|
||||
containerRef.current.style.height = 'calc(100vh - 56px)';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set dimensions immediately
|
||||
updateDimensions();
|
||||
|
||||
// Also try after a short delay
|
||||
const timeout1 = setTimeout(updateDimensions, 10);
|
||||
const timeout2 = setTimeout(updateDimensions, 100);
|
||||
|
||||
// Use ResizeObserver for reliable dimension tracking
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (containerRef.current?.parentElement && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(updateDimensions);
|
||||
resizeObserver.observe(containerRef.current.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout1);
|
||||
clearTimeout(timeout2);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNodesChange = useCallback((changes: any) => {
|
||||
onNodesChange(changes);
|
||||
setNodes(internalNodes);
|
||||
}, [onNodesChange, setNodes, internalNodes]);
|
||||
|
||||
const handleEdgesChange = useCallback((changes: any) => {
|
||||
onEdgesChange(changes);
|
||||
setEdges(internalEdges);
|
||||
}, [onEdgesChange, setEdges, internalEdges]);
|
||||
|
||||
const onNodeDragStop = useCallback(
|
||||
(_event: React.MouseEvent, node: ReactFlowNode) => {
|
||||
console.log('Node moved:', node.id, node.position);
|
||||
setNodes(internalNodes);
|
||||
},
|
||||
[setNodes, internalNodes]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: ReactFlowNode) => {
|
||||
setSelectedNode(node.id);
|
||||
}, [setSelectedNode]);
|
||||
|
||||
const handleCanvasWheel = useCallback((e: React.WheelEvent) => {
|
||||
console.log('Canvas wheel event, isOverUI:', isOverUI);
|
||||
if (isOverUI) {
|
||||
console.log('Preventing canvas zoom/scroll');
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [isOverUI]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 relative scrollbar-hide">
|
||||
<div className="w-full h-full bg-background rounded-t-xl border border-[rgb(var(--border))] border-b-0 overflow-hidden">
|
||||
<CanvasContextMenu>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{ width: '100vw', height: 'calc(100vh - 56px)' }}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={internalNodes}
|
||||
edges={internalEdges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeClick={onNodeClick}
|
||||
onWheel={handleCanvasWheel}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.5, maxZoom: 1 }}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.9 }}
|
||||
attributionPosition="bottom-left"
|
||||
className="w-full h-full"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Background
|
||||
color={resolvedTheme === 'dark' ? '#545260' : '#878593'}
|
||||
gap={16}
|
||||
/>
|
||||
<Controls
|
||||
className="bg-background border border-[rgb(var(--border))]"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
switch (node.data?.type) {
|
||||
case 'github': return '#52297A';
|
||||
case 'database': return '#181622';
|
||||
case 'docker': return '#211F2D';
|
||||
case 'function': return '#545260';
|
||||
case 'bucket': return '#878593';
|
||||
default: return '#33323E';
|
||||
}
|
||||
}}
|
||||
className="bg-card border border-[rgb(var(--border))]"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</CanvasContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Canvas() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasContent />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
interface CanvasContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export default function CanvasContextMenu({ children }: CanvasContextMenuProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from '@radix-ui/react-context-menu';
|
||||
import { Github, Database, Container, Code, HardDrive } from 'lucide-react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
const serviceOptions = [
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub Repository',
|
||||
type: 'github',
|
||||
icon: Github,
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
type: 'database',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
type: 'database',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
name: 'Docker Image',
|
||||
type: 'docker',
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Serverless Function',
|
||||
type: 'function',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'Storage Bucket',
|
||||
type: 'bucket',
|
||||
icon: HardDrive,
|
||||
},
|
||||
];
|
||||
export default function CanvasContextMenu({ children }) {
|
||||
const { addNode } = useCanvasStore();
|
||||
const handleSelect = (option, event) => {
|
||||
// Get click position relative to the canvas
|
||||
const reactFlowElement = event.target.closest('.react-flow');
|
||||
if (!reactFlowElement)
|
||||
return;
|
||||
const rect = reactFlowElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
// Generate a unique ID for the new node
|
||||
const nodeId = `${option.type}-${Date.now()}`;
|
||||
// Create the new node at click position
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: option.type,
|
||||
position: { x, y },
|
||||
data: {
|
||||
label: option.name,
|
||||
type: option.type,
|
||||
status: 'stopped',
|
||||
...(option.type === 'github' && { repo: 'user/repo' }),
|
||||
},
|
||||
};
|
||||
// Add the node to the store
|
||||
addNode(newNode);
|
||||
};
|
||||
return (_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { className: "w-full h-full", children: children }), _jsxs(ContextMenuContent, { className: "z-50 min-w-[200px] bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden", "data-ui-element": "true", children: [_jsx("div", { className: "px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900", children: _jsx("div", { className: "text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide", children: "Add Service" }) }), _jsx("div", { className: "py-1", children: serviceOptions.map((option) => (_jsxs(ContextMenuItem, { className: "flex items-center px-3 py-2 text-sm cursor-pointer transition-all duration-150 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-700 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:bg-blue-50 dark:focus:bg-gray-700 group", onSelect: (event) => handleSelect(option, event), children: [_jsx("div", { className: "w-5 h-5 rounded bg-gray-100 dark:bg-gray-600 flex items-center justify-center mr-2 group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0", children: _jsx(option.icon, { className: "w-2.5 h-2.5 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" }) }), _jsx("div", { className: "flex-1 font-medium text-sm truncate", children: option.name })] }, option.id))) })] })] }));
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import CanvasContextMenu from './CanvasContextMenu';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
|
||||
vi.mock('../store/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseCanvasStore = vi.mocked(useCanvasStore);
|
||||
const mockAddNode = vi.fn();
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CanvasContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseCanvasStore.mockReturnValue({
|
||||
addNode: mockAddNode,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNode: null,
|
||||
isCommandPaletteOpen: false,
|
||||
sidebarOpen: true,
|
||||
setNodes: vi.fn(),
|
||||
setEdges: vi.fn(),
|
||||
setSelectedNode: vi.fn(),
|
||||
setCommandPaletteOpen: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders children as the trigger', () => {
|
||||
render(
|
||||
<CanvasContextMenu>
|
||||
<div data-testid="child">Canvas Area</div>
|
||||
</CanvasContextMenu>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Canvas Area')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders context menu with canvas content', () => {
|
||||
render(
|
||||
<CanvasContextMenu>
|
||||
<div>Canvas</div>
|
||||
</CanvasContextMenu>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Canvas')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('context menu structure', () => {
|
||||
it('has proper DOM structure', () => {
|
||||
const { container } = render(
|
||||
<CanvasContextMenu>
|
||||
<div data-testid="canvas">Canvas</div>
|
||||
</CanvasContextMenu>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('store integration', () => {
|
||||
it('calls useCanvasStore to get addNode', () => {
|
||||
render(
|
||||
<CanvasContextMenu>
|
||||
<div>Canvas</div>
|
||||
</CanvasContextMenu>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(mockUseCanvasStore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('node creation patterns', () => {
|
||||
it('node ID follows pattern type-timestamp', () => {
|
||||
const type = 'github';
|
||||
const timestamp = Date.now();
|
||||
const expectedPattern = new RegExp(`^${type}-\\d+$`);
|
||||
const nodeId = `${type}-${timestamp}`;
|
||||
|
||||
expect(nodeId).toMatch(expectedPattern);
|
||||
});
|
||||
|
||||
it('node data includes required fields', () => {
|
||||
const mockNode = {
|
||||
id: 'github-123',
|
||||
type: 'github',
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
label: 'GitHub Repository',
|
||||
type: 'github',
|
||||
status: 'stopped',
|
||||
repo: 'user/repo',
|
||||
},
|
||||
};
|
||||
|
||||
expect(mockNode.data).toHaveProperty('label');
|
||||
expect(mockNode.data).toHaveProperty('type');
|
||||
expect(mockNode.data).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('github type includes repo field', () => {
|
||||
const githubNode = {
|
||||
type: 'github',
|
||||
data: { label: 'GitHub Repository', type: 'github', status: 'stopped', repo: 'user/repo' },
|
||||
};
|
||||
|
||||
expect(githubNode.data).toHaveProperty('repo');
|
||||
});
|
||||
|
||||
it('non-github types do not include repo field', () => {
|
||||
const dockerNode = {
|
||||
type: 'docker',
|
||||
data: { label: 'Docker Image', type: 'docker', status: 'stopped' },
|
||||
};
|
||||
|
||||
expect(dockerNode.data).not.toHaveProperty('repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('position calculation', () => {
|
||||
it('calculates position relative to canvas element', () => {
|
||||
const clientX = 200;
|
||||
const clientY = 150;
|
||||
const rectLeft = 50;
|
||||
const rectTop = 30;
|
||||
|
||||
const x = clientX - rectLeft;
|
||||
const y = clientY - rectTop;
|
||||
|
||||
expect(x).toBe(150);
|
||||
expect(y).toBe(120);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function CommandPalette({ open, onClose }: CommandPaletteProps): import("react/jsx-runtime").JSX.Element | null;
|
||||
export {};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Command } from 'cmdk';
|
||||
import { Github, Database, Container, Code, HardDrive, Plus, Search, Layers, Server } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
const serviceOptions = [
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub Repository',
|
||||
description: 'Deploy from a GitHub repository',
|
||||
icon: Github,
|
||||
type: 'github',
|
||||
gradient: 'from-violet-500/10 to-violet-500/5',
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Add a PostgreSQL database',
|
||||
icon: Database,
|
||||
type: 'database',
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'Add a Redis cache',
|
||||
icon: Database,
|
||||
type: 'database',
|
||||
gradient: 'from-red-500/10 to-red-500/5',
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
name: 'Docker Image',
|
||||
description: 'Deploy a Docker image',
|
||||
icon: Container,
|
||||
type: 'docker',
|
||||
gradient: 'from-cyan-500/10 to-cyan-500/5',
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
name: 'Serverless Function',
|
||||
description: 'Add a serverless function',
|
||||
icon: Code,
|
||||
type: 'function',
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
},
|
||||
{
|
||||
id: 'bucket',
|
||||
name: 'Storage Bucket',
|
||||
description: 'Add object storage',
|
||||
icon: HardDrive,
|
||||
type: 'bucket',
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
},
|
||||
];
|
||||
const quickActions = [
|
||||
{ name: 'New Project', icon: Layers, shortcut: 'P' },
|
||||
{ name: 'Add Server', icon: Server, shortcut: 'S' },
|
||||
];
|
||||
export default function CommandPalette({ open, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { addNode } = useCanvasStore();
|
||||
useEffect(() => {
|
||||
const down = (e) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (open) {
|
||||
document.addEventListener('keydown', down);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', down);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
const handleSelect = (option) => {
|
||||
const nodeId = `${option.type}-${Date.now()}`;
|
||||
const position = {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
};
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: option.type,
|
||||
position,
|
||||
data: {
|
||||
label: option.name,
|
||||
type: option.type,
|
||||
status: 'stopped',
|
||||
...(option.type === 'github' && { repo: 'user/repo' }),
|
||||
},
|
||||
};
|
||||
addNode(newNode);
|
||||
onClose();
|
||||
};
|
||||
if (!open)
|
||||
return null;
|
||||
return (_jsx("div", { className: "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-start justify-center pt-[15vh] p-4 animate-fade-in", children: _jsx("div", { className: "w-full max-w-xl animate-command-in", children: _jsxs(Command, { className: "bg-card/95 backdrop-blur-2xl rounded-2xl shadow-modal border border-border/50 overflow-hidden", children: [_jsxs("div", { className: "flex items-center border-b border-border/50 px-4 py-3", children: [_jsx(Search, { className: "w-5 h-5 text-muted-foreground mr-3 shrink-0" }), _jsx(Command.Input, { placeholder: "What would you like to create?", value: search, onValueChange: setSearch, className: "flex-1 py-2 bg-transparent outline-none text-foreground placeholder-muted-foreground text-sm", autoFocus: true }), _jsx("kbd", { className: "ml-3 px-2 py-1 text-[10px] bg-muted/50 text-muted-foreground rounded-md font-mono border border-border/50", children: "ESC" })] }), _jsxs(Command.List, { className: "max-h-[350px] overflow-y-auto p-2 scrollbar-thin", children: [_jsx(Command.Empty, { className: "py-10 text-center text-sm text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(Search, { className: "w-8 h-8 text-muted-foreground/50" }), _jsx("span", { children: "No services found." })] }) }), search === '' && (_jsx("div", { className: "px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider", children: "Quick Actions" })), search === '' && quickActions.map((action) => (_jsxs(Command.Item, { className: cn('flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150', 'hover:bg-muted/50 data-[selected=true]:bg-muted/50', 'text-foreground'), children: [_jsx("div", { className: "w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center", children: _jsx(action.icon, { className: "w-4 h-4 text-muted-foreground" }) }), _jsx("span", { className: "flex-1 font-medium", children: action.name }), _jsxs("kbd", { className: "px-1.5 py-0.5 text-[10px] bg-background text-muted-foreground rounded border border-border/50 font-mono", children: ["\u2318", action.shortcut] })] }, action.name))), _jsx("div", { className: "px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mt-2", children: "Create New Service" }), serviceOptions.map((option) => (_jsxs(Command.Item, { onSelect: () => handleSelect(option), className: cn('flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150', 'hover:bg-muted/50 data-[selected=true]:bg-muted/50', 'text-foreground group'), children: [_jsx("div", { className: cn("w-9 h-9 rounded-xl flex items-center justify-center transition-colors", "bg-gradient-to-br", option.gradient, "group-hover:from-primary/10 group-hover:to-primary/5"), children: _jsx(option.icon, { className: "w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "font-medium", children: option.name }), _jsx("div", { className: "text-xs text-muted-foreground mt-0.5", children: option.description })] }), _jsx(Plus, { className: "w-4 h-4 text-muted-foreground/50 group-hover:text-primary group-hover:text-primary transition-colors" })] }, option.id)))] }), _jsx("div", { className: "border-t border-border/50 px-4 py-3 bg-muted/20", children: _jsxs("div", { className: "flex items-center justify-center text-[11px] text-muted-foreground gap-6", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "\u2191\u2193" }), _jsx("span", { children: "Navigate" })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "\u21B5" }), _jsx("span", { children: "Select" })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("kbd", { className: "px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono", children: "ESC" }), _jsx("span", { children: "Close" })] })] }) })] }) }) }));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import CommandPalette from './CommandPalette';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
|
||||
vi.mock('../store/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseCanvasStore = vi.mocked(useCanvasStore);
|
||||
const mockAddNode = vi.fn();
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseCanvasStore.mockReturnValue({
|
||||
addNode: mockAddNode,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNode: null,
|
||||
isCommandPaletteOpen: false,
|
||||
sidebarOpen: true,
|
||||
setNodes: vi.fn(),
|
||||
setEdges: vi.fn(),
|
||||
setSelectedNode: vi.fn(),
|
||||
setCommandPaletteOpen: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('does not render when open is false', () => {
|
||||
render(<CommandPalette open={false} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.queryByPlaceholderText('What would you like to create?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders when open is true', () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByPlaceholderText('What would you like to create?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all service options', () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('GitHub Repository')).toBeInTheDocument();
|
||||
expect(screen.getByText('PostgreSQL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Redis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Docker Image')).toBeInTheDocument();
|
||||
expect(screen.getByText('Serverless Function')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage Bucket')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quick actions when search is empty', () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('New Project')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows keyboard shortcuts hint', () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Navigate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select')).toBeInTheDocument();
|
||||
expect(screen.getByText('Close')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard interactions', () => {
|
||||
it('closes on Escape key', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes on Cmd+K', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'k', metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on Ctrl+K', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'k', ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search functionality', () => {
|
||||
it('shows PostgreSQL when searching for postgres', async () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
const input = screen.getByPlaceholderText('What would you like to create?');
|
||||
await userEvent.type(input, 'postgres');
|
||||
|
||||
expect(screen.getByText('PostgreSQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no results match', async () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
const input = screen.getByPlaceholderText('What would you like to create?');
|
||||
await userEvent.type(input, 'nonexistent');
|
||||
|
||||
expect(screen.getByText('No services found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides quick actions when searching', async () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
const input = screen.getByPlaceholderText('What would you like to create?');
|
||||
await userEvent.type(input, 'git');
|
||||
|
||||
expect(screen.queryByText('New Project')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service selection', () => {
|
||||
it('adds node when service is selected', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CommandPalette open={true} onClose={onClose} />, { wrapper: createWrapper() });
|
||||
|
||||
const githubOption = screen.getByText('GitHub Repository');
|
||||
await userEvent.click(githubOption);
|
||||
|
||||
expect(mockAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'github',
|
||||
data: expect.objectContaining({
|
||||
label: 'GitHub Repository',
|
||||
type: 'github',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates unique node IDs', async () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
const dockerOption = screen.getByText('Docker Image');
|
||||
await userEvent.click(dockerOption);
|
||||
|
||||
const call = mockAddNode.mock.calls[0][0];
|
||||
expect(call.id).toMatch(/^docker-\d+$/);
|
||||
});
|
||||
|
||||
it('adds repo data for GitHub type', async () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
const githubOption = screen.getByText('GitHub Repository');
|
||||
await userEvent.click(githubOption);
|
||||
|
||||
expect(mockAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
repo: 'user/repo',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('body scroll lock', () => {
|
||||
it('locks body scroll when open', () => {
|
||||
render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
it('restores body scroll when closed', () => {
|
||||
const { unmount } = render(<CommandPalette open={true} onClose={vi.fn()} />, { wrapper: createWrapper() });
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Command } from 'cmdk';
|
||||
import {
|
||||
Github,
|
||||
Database,
|
||||
Container,
|
||||
Code,
|
||||
HardDrive,
|
||||
import {
|
||||
Github,
|
||||
Database,
|
||||
Container,
|
||||
Code,
|
||||
HardDrive,
|
||||
Plus,
|
||||
Search
|
||||
Search,
|
||||
Layers,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
@@ -23,6 +25,7 @@ interface ServiceOption {
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: 'github' | 'database' | 'docker' | 'function' | 'bucket';
|
||||
gradient: string;
|
||||
}
|
||||
|
||||
const serviceOptions: ServiceOption[] = [
|
||||
@@ -32,6 +35,7 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Deploy from a GitHub repository',
|
||||
icon: Github,
|
||||
type: 'github',
|
||||
gradient: 'from-violet-500/10 to-violet-500/5',
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
@@ -39,6 +43,7 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Add a PostgreSQL database',
|
||||
icon: Database,
|
||||
type: 'database',
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
@@ -46,6 +51,7 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Add a Redis cache',
|
||||
icon: Database,
|
||||
type: 'database',
|
||||
gradient: 'from-red-500/10 to-red-500/5',
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
@@ -53,6 +59,7 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Deploy a Docker image',
|
||||
icon: Container,
|
||||
type: 'docker',
|
||||
gradient: 'from-cyan-500/10 to-cyan-500/5',
|
||||
},
|
||||
{
|
||||
id: 'function',
|
||||
@@ -60,6 +67,7 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Add a serverless function',
|
||||
icon: Code,
|
||||
type: 'function',
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
},
|
||||
{
|
||||
id: 'bucket',
|
||||
@@ -67,9 +75,15 @@ const serviceOptions: ServiceOption[] = [
|
||||
description: 'Add object storage',
|
||||
icon: HardDrive,
|
||||
type: 'bucket',
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ name: 'New Project', icon: Layers, shortcut: 'P' },
|
||||
{ name: 'Add Server', icon: Server, shortcut: 'S' },
|
||||
];
|
||||
|
||||
export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { addNode } = useCanvasStore();
|
||||
@@ -87,24 +101,23 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', down);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', down);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const handleSelect = (option: ServiceOption) => {
|
||||
// Generate a unique ID for the new node
|
||||
const nodeId = `${option.type}-${Date.now()}`;
|
||||
|
||||
// Calculate a random position for the new node
|
||||
const position = {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
};
|
||||
|
||||
// Create the new node
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: option.type,
|
||||
@@ -117,91 +130,113 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
},
|
||||
};
|
||||
|
||||
// Add the node to the store
|
||||
addNode(newNode);
|
||||
|
||||
console.log('Added service:', option);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
data-ui-element="true"
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<Command className="rounded-2xl">
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<Search className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<Command.Input
|
||||
placeholder="What would you like to create?"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className="flex-1 py-2 bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder-gray-500 text-sm"
|
||||
/>
|
||||
<kbd className="ml-3 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
|
||||
ESC
|
||||
</kbd>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-start justify-center pt-[15vh] p-4 animate-fade-in">
|
||||
<div className="w-full max-w-xl animate-command-in">
|
||||
<Command className="bg-card/95 backdrop-blur-2xl rounded-2xl shadow-modal border border-border/50 overflow-hidden">
|
||||
<div className="flex items-center border-b border-border/50 px-4 py-3">
|
||||
<Search className="w-5 h-5 text-muted-foreground mr-3 shrink-0" />
|
||||
<Command.Input
|
||||
placeholder="What would you like to create?"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className="flex-1 py-2 bg-transparent outline-none text-foreground placeholder-muted-foreground text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<kbd className="ml-3 px-2 py-1 text-[10px] bg-muted/50 text-muted-foreground rounded-md font-mono border border-border/50">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<Command.List className="max-h-[350px] overflow-y-auto p-2 scrollbar-thin">
|
||||
<Command.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Search className="w-8 h-8 text-muted-foreground/50" />
|
||||
<span>No services found.</span>
|
||||
</div>
|
||||
</Command.Empty>
|
||||
|
||||
{search === '' && (
|
||||
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Quick Actions
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search === '' && quickActions.map((action) => (
|
||||
<Command.Item
|
||||
key={action.name}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150',
|
||||
'hover:bg-muted/50 data-[selected=true]:bg-muted/50',
|
||||
'text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
<action.icon className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium">{action.name}</span>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-background text-muted-foreground rounded border border-border/50 font-mono">
|
||||
⌘{action.shortcut}
|
||||
</kbd>
|
||||
</Command.Item>
|
||||
))}
|
||||
|
||||
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mt-2">
|
||||
Create New Service
|
||||
</div>
|
||||
|
||||
<Command.List className="max-h-[350px] overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
||||
<Command.Empty className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No services found.
|
||||
</Command.Empty>
|
||||
|
||||
{serviceOptions.map((option) => (
|
||||
<Command.Item
|
||||
key={option.id}
|
||||
onSelect={() => handleSelect(option)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-3 rounded-xl text-sm cursor-pointer transition-all duration-150',
|
||||
'hover:bg-blue-50 dark:hover:bg-gray-700',
|
||||
'focus:bg-blue-50 dark:focus:bg-gray-700',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'hover:text-blue-600 dark:hover:text-blue-400'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-600 flex items-center justify-center group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0">
|
||||
<option.icon className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
|
||||
{serviceOptions.map((option) => (
|
||||
<Command.Item
|
||||
key={option.id}
|
||||
onSelect={() => handleSelect(option)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm cursor-pointer transition-all duration-150',
|
||||
'hover:bg-muted/50 data-[selected=true]:bg-muted/50',
|
||||
'text-foreground group'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-xl flex items-center justify-center transition-colors",
|
||||
"bg-gradient-to-br",
|
||||
option.gradient,
|
||||
"group-hover:from-primary/10 group-hover:to-primary/5"
|
||||
)}>
|
||||
<option.icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{option.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<Plus className="w-4 h-4 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</div>
|
||||
<Plus className="w-4 h-4 text-muted-foreground/50 group-hover:text-primary group-hover:text-primary transition-colors" />
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-center text-xs text-gray-500 dark:text-gray-400 gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
|
||||
↑↓
|
||||
</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
|
||||
↵
|
||||
</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
|
||||
ESC
|
||||
</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
<div className="border-t border-border/50 px-4 py-3 bg-muted/20">
|
||||
<div className="flex items-center justify-center text-[11px] text-muted-foreground gap-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono">↑↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono">↵</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-background/80 border border-border/50 rounded text-[10px] font-mono">ESC</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default function Layout(): import("react/jsx-runtime").JSX.Element;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
|
||||
vi.mock('../store/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useAuth', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CommandPalette', () => ({
|
||||
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
|
||||
open ? <div data-testid="command-palette">Command Palette</div> : null
|
||||
),
|
||||
}));
|
||||
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const mockUseCanvasStore = vi.mocked(useCanvasStore);
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
|
||||
const mockSetCommandPaletteOpen = vi.fn();
|
||||
const mockSetSidebarOpen = vi.fn();
|
||||
const mockLogout = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
function createWrapper(initialRoute = '/') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const createMockStore = () => ({
|
||||
isCommandPaletteOpen: false,
|
||||
setCommandPaletteOpen: mockSetCommandPaletteOpen,
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
addNode: vi.fn(),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNode: null,
|
||||
setNodes: vi.fn(),
|
||||
setEdges: vi.fn(),
|
||||
setSelectedNode: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
});
|
||||
|
||||
const createMockAuth = (overrides = {}) => ({
|
||||
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: mockLogout,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseCanvasStore.mockReturnValue(createMockStore());
|
||||
mockUseAuth.mockReturnValue(createMockAuth());
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders sidebar with logo', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getAllByText('Containr').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Self-hosted PaaS').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders navigation items', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument();
|
||||
expect(screen.getByText('Analytics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Git Integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Infrastructure')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders user avatar with initials', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders documentation section', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Documentation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learn how to deploy your first service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quick search button', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Quick Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders new deployment button', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getAllByText('New Deployment').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('page title', () => {
|
||||
it('shows Dashboard as default title', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Projects title on projects page', () => {
|
||||
render(<Layout />, { wrapper: createWrapper('/projects') });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Projects' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Analytics title on analytics page', () => {
|
||||
render(<Layout />, { wrapper: createWrapper('/analytics') });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Analytics' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status badge', () => {
|
||||
it('shows operational status on dashboard', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('All systems operational')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides operational status on other pages', () => {
|
||||
render(<Layout />, { wrapper: createWrapper('/projects') });
|
||||
|
||||
expect(screen.queryByText('All systems operational')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command palette', () => {
|
||||
it('opens command palette when quick search clicked', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
const quickSearchButton = screen.getByText('Quick Search');
|
||||
fireEvent.click(quickSearchButton);
|
||||
|
||||
expect(mockSetCommandPaletteOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('opens command palette when new deployment clicked', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
const newDeploymentButton = screen.getByText('New Deployment');
|
||||
fireEvent.click(newDeploymentButton);
|
||||
|
||||
expect(mockSetCommandPaletteOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar toggle', () => {
|
||||
it('toggles sidebar when menu button clicked', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button').filter(btn => {
|
||||
const svg = btn.querySelector('svg');
|
||||
return svg && (svg.classList.contains('lucide-x') || svg.classList.contains('lucide-menu'));
|
||||
});
|
||||
|
||||
expect(toggleButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user dropdown', () => {
|
||||
it('displays user avatar in header', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays fallback when user has no name', () => {
|
||||
mockUseAuth.mockReturnValue(createMockAuth({
|
||||
user: { id: '1', name: '', email: 'user@test.com', created_at: '', updated_at: '' },
|
||||
}));
|
||||
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('u')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation badges', () => {
|
||||
it('shows count badges on navigation items', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Beta badge on Canvas', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section grouping', () => {
|
||||
it('renders navigation sections', () => {
|
||||
render(<Layout />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Build')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deploy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Security').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+318
-111
@@ -1,188 +1,395 @@
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { Plus, Activity, Settings, GitBranch, Database, Menu, X, Server, Folder, Github, Cpu, BarChart3 } from 'lucide-react';
|
||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Activity,
|
||||
Settings,
|
||||
Database,
|
||||
Menu,
|
||||
X,
|
||||
Server,
|
||||
Folder,
|
||||
Github,
|
||||
Cpu,
|
||||
BarChart3,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Zap,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Bell,
|
||||
Rocket,
|
||||
Workflow,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { ThemeToggle } from './ui/theme-toggle';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import CommandPalette from './CommandPalette';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Activity },
|
||||
{ name: 'Projects', href: '/projects', icon: Folder },
|
||||
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ name: 'Git Integration', href: '/git', icon: Github },
|
||||
{ name: 'Infrastructure', href: '/infrastructure', icon: Server },
|
||||
{ name: 'Node Agents', href: '/agents', icon: Cpu },
|
||||
{ name: 'Deployments', href: '/deployments', icon: GitBranch },
|
||||
{ name: 'Databases', href: '/databases', icon: Database },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
{ name: 'Dashboard', href: '/', icon: Activity, section: 'overview', badge: null },
|
||||
{ name: 'Projects', href: '/projects', icon: Folder, section: 'overview', badge: '12' },
|
||||
{ name: 'Analytics', href: '/analytics', icon: BarChart3, section: 'overview', badge: null },
|
||||
{ name: 'Canvas', href: '/canvas', icon: Workflow, section: 'build', badge: 'Beta' },
|
||||
{ name: 'Git Integration', href: '/git', icon: Github, section: 'deploy', badge: null },
|
||||
{ name: 'Infrastructure', href: '/infrastructure', icon: Server, section: 'deploy', badge: '3' },
|
||||
{ name: 'Node Agents', href: '/agents', icon: Cpu, section: 'deploy', badge: null },
|
||||
{ name: 'Databases', href: '/databases', icon: Database, section: 'resources', badge: '5' },
|
||||
{ name: 'Security', href: '/security', icon: Shield, section: 'security', badge: null },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings, section: 'settings', badge: null },
|
||||
];
|
||||
|
||||
const sections = {
|
||||
overview: { label: 'Overview', icon: Layers, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
build: { label: 'Build', icon: Rocket, color: 'text-violet-500', bg: 'bg-violet-500/10' },
|
||||
deploy: { label: 'Deploy', icon: Zap, color: 'text-amber-500', bg: 'bg-amber-500/10' },
|
||||
resources: { label: 'Resources', icon: Database, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||
security: { label: 'Security', icon: Shield, color: 'text-red-500', bg: 'bg-red-500/10' },
|
||||
settings: { label: 'Settings', icon: Settings, color: 'text-muted-foreground', bg: 'bg-muted/50' },
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const { isCommandPaletteOpen, setCommandPaletteOpen, sidebarOpen, setSidebarOpen } = useCanvasStore();
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const getPageTitle = () => {
|
||||
const currentNav = navigation.find(item => item.href === location.pathname);
|
||||
return currentNav ? currentNav.name : 'Dashboard';
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const groupedNavigation = navigation.reduce((acc, item) => {
|
||||
if (!acc[item.section]) acc[item.section] = [];
|
||||
acc[item.section].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof navigation>);
|
||||
|
||||
const NavLink = ({ item, mobile = false }: { item: typeof navigation[0]; mobile?: boolean }) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
const isHovered = hoveredItem === item.name;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={item.href}
|
||||
onMouseEnter={() => setHoveredItem(item.name)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-300",
|
||||
isActive
|
||||
? "text-primary bg-primary/10 dark:bg-primary/15 shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
||||
mobile && "text-base py-3"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative p-1.5 rounded-lg transition-all duration-300",
|
||||
isActive ? "bg-primary/15" : isHovered ? "bg-muted/70" : "bg-transparent"
|
||||
)}>
|
||||
<item.icon className={cn(
|
||||
"w-[18px] h-[18px] shrink-0 transition-all duration-300",
|
||||
isActive ? "text-primary" : isHovered ? "text-foreground" : "text-muted-foreground"
|
||||
)} />
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 rounded-lg bg-primary/10 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
{item.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"ml-auto text-[9px] font-semibold px-1.5 py-0 h-4",
|
||||
item.badge === 'New' || item.badge === 'Beta'
|
||||
? "bg-violet-500/10 text-violet-500 border-violet-500/20"
|
||||
: "bg-muted/50 text-muted-foreground border-border/50"
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-5 bg-gradient-to-b from-primary to-primary/50 rounded-r-full" />
|
||||
)}
|
||||
<ChevronRight className={cn(
|
||||
"w-4 h-4 ml-auto opacity-0 -translate-x-2 transition-all duration-300",
|
||||
isHovered && "opacity-100 translate-x-0"
|
||||
)} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex bg-background overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<div
|
||||
className={`
|
||||
${sidebarOpen ? 'w-64' : 'w-0'}
|
||||
transition-all duration-300 ease-in-out
|
||||
bg-card border-r border-[rgb(var(--border))]
|
||||
flex flex-col overflow-hidden flex-shrink-0
|
||||
hidden lg:flex
|
||||
`}
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="absolute inset-0 dot-grid opacity-30 dark:opacity-15" />
|
||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-radial from-primary/10 via-transparent to-transparent blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-radial from-violet-500/10 via-transparent to-transparent blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-radial from-primary/5 via-transparent to-transparent blur-3xl" />
|
||||
<div className="absolute inset-0 mesh-gradient opacity-50" />
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden lg:flex flex-col border-r border-border/40 bg-card/80 backdrop-blur-xl transition-all duration-300 ease-out flex-shrink-0 relative",
|
||||
sidebarOpen ? "w-72" : "w-0 opacity-0 -translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-[rgb(var(--border))] flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Containr
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Self-hosted PaaS
|
||||
</p>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/2 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
<div className="relative p-4 border-b border-border/40">
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary via-violet-500 to-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/20 transition-all duration-500 group-hover:shadow-xl group-hover:shadow-primary/30 group-hover:scale-105 p-1.5">
|
||||
<img src="/containr.svg" alt="Containr" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-card">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500 animate-ping opacity-75" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">Containr</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-none font-medium">Self-hosted PaaS</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-2 overflow-y-auto scrollbar-hide">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
<nav className="flex-1 p-3 overflow-y-auto scrollbar-thin">
|
||||
{Object.entries(groupedNavigation).map(([section, items]) => {
|
||||
const sectionConfig = sections[section as keyof typeof sections];
|
||||
return (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="w-full justify-start gap-3 h-10"
|
||||
asChild
|
||||
>
|
||||
<Link to={item.href}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</Button>
|
||||
<div key={section} className="mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-1">
|
||||
<div className={cn("p-1 rounded-md", sectionConfig?.bg)}>
|
||||
{sectionConfig && <sectionConfig.icon className={cn("w-3 h-3", sectionConfig.color)} />}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{sectionConfig?.label || section}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<NavLink key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Add Service Button */}
|
||||
<div className="p-4 border-t border-[rgb(var(--border))] flex-shrink-0">
|
||||
<div className="relative p-3 border-t border-border/40 space-y-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/5 via-violet-500/5 to-transparent border border-primary/10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BookOpen className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium">Documentation</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">Learn how to deploy your first service</p>
|
||||
<Button variant="outline" size="sm" className="w-full h-8 text-xs">
|
||||
View Docs
|
||||
<ExternalLink className="w-3 h-3 ml-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-muted/30 hover:bg-muted/50 text-muted-foreground hover:text-foreground text-sm transition-all duration-200 group border border-transparent hover:border-border/50"
|
||||
>
|
||||
<Search className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
<span>Quick Search</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">⌘</kbd>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">K</kbd>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="w-full gap-2"
|
||||
size="default"
|
||||
className="w-full gap-2 h-11 rounded-xl bg-gradient-to-r from-primary via-violet-500 to-primary bg-[length:200%_100%] hover:bg-right transition-all duration-500 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Service
|
||||
New Deployment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative min-w-0">
|
||||
{/* Top Bar */}
|
||||
<div className="h-16 bg-card border-b border-[rgb(var(--border))] flex items-center px-4 gap-4 flex-shrink-0">
|
||||
{/* Mobile Menu */}
|
||||
<header className="h-16 glass-heavy border-b border-border/40 flex items-center px-4 gap-4 flex-shrink-0 sticky top-0 z-40">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-muted/50">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<div className="p-6 border-b border-[rgb(var(--border))]">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Containr
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Self-hosted PaaS
|
||||
</p>
|
||||
<SheetContent side="left" className="w-80 p-0 bg-card/95 backdrop-blur-2xl border-r border-border/40">
|
||||
<div className="p-4 border-b border-border/40">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary via-violet-500 to-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/20 p-1.5">
|
||||
<img src="/containr.svg" alt="Containr" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold">Containr</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-none">Self-hosted PaaS</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="w-full justify-start gap-3 h-10"
|
||||
asChild
|
||||
>
|
||||
<Link to={item.href}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink key={item.name} item={item} mobile />
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-[var(--border)]">
|
||||
<Separator />
|
||||
<div className="p-3">
|
||||
<Button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="w-full gap-2"
|
||||
size="default"
|
||||
className="w-full gap-2 h-11 rounded-xl bg-gradient-to-r from-primary to-violet-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Service
|
||||
New Deployment
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Desktop Sidebar Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="hidden lg:flex"
|
||||
className="hidden lg:flex hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-foreground truncate">
|
||||
{getPageTitle()}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-foreground truncate">
|
||||
{getPageTitle()}
|
||||
</h2>
|
||||
{location.pathname === '/' && (
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1 animate-pulse" />
|
||||
All systems operational
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Avatar */}
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt="User" />
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 px-3 py-2 rounded-xl bg-muted/30 hover:bg-muted/50 text-muted-foreground hover:text-foreground text-sm transition-all duration-200 group border border-transparent hover:border-border/50"
|
||||
>
|
||||
<Search className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
<span className="hidden lg:inline">Search...</span>
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">⌘</kbd>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] bg-background/80 rounded-md border border-border/50 font-mono shadow-sm">K</kbd>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="hidden sm:flex gap-2 h-9 rounded-xl bg-gradient-to-r from-primary to-violet-500 shadow-md shadow-primary/10 hover:shadow-lg hover:shadow-primary/20"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">New</span>
|
||||
</Button>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="flex-1 relative min-h-0 overflow-auto">
|
||||
<Button variant="ghost" size="icon" className="relative hover:bg-muted/50 rounded-xl">
|
||||
<Bell className="h-4 w-4" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-destructive rounded-full">
|
||||
<span className="absolute inset-0 rounded-full bg-destructive animate-ping opacity-75" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-2 hover:bg-muted/50 transition-colors rounded-xl">
|
||||
<Avatar className="h-8 w-8 ring-2 ring-primary/20 ring-offset-1 ring-offset-background">
|
||||
<AvatarImage src="/avatars/01.png" alt="User" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-primary/30 to-violet-500/30 text-primary font-medium text-sm">
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground hidden sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60 glass-heavy border-border/40 shadow-xl rounded-xl">
|
||||
<div className="px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-primary/20">
|
||||
<AvatarImage src="/avatars/01.png" alt="User" />
|
||||
<AvatarFallback className="bg-gradient-to-br from-primary/30 to-violet-500/30 text-primary font-medium">
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-semibold">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email || 'user@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-border/50" />
|
||||
<DropdownMenuItem asChild className="cursor-pointer focus:bg-muted/50 rounded-lg mx-1">
|
||||
<Link to="/settings" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer focus:bg-muted/50 rounded-lg mx-1">
|
||||
<Building2 className="w-4 h-4 mr-2" />
|
||||
Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-border/50" />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive cursor-pointer focus:bg-destructive/10 rounded-lg mx-1">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 relative min-h-0 overflow-auto bg-background/30">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Floating Action Button */}
|
||||
<Button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
size="icon"
|
||||
className="lg:hidden fixed right-4 bottom-4 w-14 h-14 rounded-full shadow-lg z-50"
|
||||
className="lg:hidden fixed right-5 bottom-5 w-14 h-14 rounded-2xl shadow-xl bg-gradient-to-r from-primary to-violet-500 hover:scale-105 transition-transform shadow-primary/20"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
<span className="sr-only">Add Service</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette
|
||||
open={isCommandPaletteOpen}
|
||||
onClose={() => setCommandPaletteOpen(false)}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
interface AnalyticsOverviewProps {
|
||||
timeRange: string;
|
||||
}
|
||||
export declare function AnalyticsOverview({ timeRange }: AnalyticsOverviewProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '@/lib/api';
|
||||
import { TrendingUp, Users, Eye, MousePointer, Clock, Activity, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
export function AnalyticsOverview({ timeRange }) {
|
||||
const { data: overviewData, isLoading, error } = useQuery({
|
||||
queryKey: ['analytics-overview', timeRange],
|
||||
queryFn: () => analyticsApi.getOverview(timeRange),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
if (isLoading) {
|
||||
return (_jsx("div", { className: "grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs(Card, { className: "animate-pulse", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0 pb-2", children: [_jsx("div", { className: "h-4 bg-gray-200 rounded w-20" }), _jsx("div", { className: "h-4 w-4 bg-gray-200 rounded" })] }), _jsxs(CardContent, { children: [_jsx("div", { className: "h-8 bg-gray-200 rounded w-16 mb-2" }), _jsx("div", { className: "h-4 bg-gray-200 rounded w-24" })] })] }, i))) }));
|
||||
}
|
||||
if (error) {
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "text-center text-red-600", children: "Failed to load analytics data. Please try again later." }) }) }));
|
||||
}
|
||||
const metrics = [
|
||||
{
|
||||
title: 'Unique Visitors',
|
||||
value: overviewData?.visitors.current.toLocaleString() || '0',
|
||||
change: overviewData?.visitors.change || 0,
|
||||
trend: overviewData?.visitors.trend || 'up',
|
||||
icon: Users,
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
title: 'Page Views',
|
||||
value: overviewData?.pageviews.current.toLocaleString() || '0',
|
||||
change: overviewData?.pageviews.change || 0,
|
||||
trend: overviewData?.pageviews.trend || 'up',
|
||||
icon: Eye,
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
value: overviewData?.sessions.current.toLocaleString() || '0',
|
||||
change: overviewData?.sessions.change || 0,
|
||||
trend: overviewData?.sessions.trend || 'up',
|
||||
icon: MousePointer,
|
||||
format: 'number'
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
value: `${overviewData?.bounceRate.current || 0}%`,
|
||||
change: overviewData?.bounceRate.change || 0,
|
||||
trend: overviewData?.bounceRate.trend || 'up',
|
||||
icon: Activity,
|
||||
format: 'percentage'
|
||||
},
|
||||
{
|
||||
title: 'Session Duration',
|
||||
value: overviewData ?
|
||||
`${Math.floor(overviewData.sessionDuration.current / 60)}m ${overviewData.sessionDuration.current % 60}s` :
|
||||
'0m 0s',
|
||||
change: overviewData?.sessionDuration.change || 0,
|
||||
trend: overviewData?.sessionDuration.trend || 'up',
|
||||
icon: Clock,
|
||||
format: 'duration'
|
||||
},
|
||||
{
|
||||
title: 'Conversion Rate',
|
||||
value: `${overviewData?.conversionRate.current || 0}%`,
|
||||
change: overviewData?.conversionRate.change || 0,
|
||||
trend: overviewData?.conversionRate.trend || 'up',
|
||||
icon: TrendingUp,
|
||||
format: 'percentage'
|
||||
}
|
||||
];
|
||||
const getTrendIcon = (trend) => {
|
||||
return trend === 'up' ? (_jsx(ArrowUp, { className: "w-4 h-4 text-green-500" })) : (_jsx(ArrowDown, { className: "w-4 h-4 text-red-500" }));
|
||||
};
|
||||
const getTrendColor = (trend) => {
|
||||
return trend === 'up' ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
return (_jsx("div", { className: "grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6", children: metrics.map((metric) => (_jsxs(Card, { className: "relative overflow-hidden", children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0 pb-2", children: [_jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: metric.title }), _jsx(metric.icon, { className: "h-4 w-4 text-muted-foreground" })] }), _jsxs(CardContent, { children: [_jsx("div", { className: "text-2xl font-bold", children: metric.value }), _jsxs("div", { className: "flex items-center space-x-1 text-xs", children: [getTrendIcon(metric.trend), _jsxs("span", { className: getTrendColor(metric.trend), children: [Math.abs(metric.change), "%"] }), _jsx("span", { className: "text-muted-foreground", children: "from last period" })] })] })] }, metric.title))) }));
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AnalyticsOverview } from './AnalyticsOverview';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
analyticsApi: {
|
||||
getOverview: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { analyticsApi } from '@/lib/api';
|
||||
|
||||
const mockAnalyticsApi = vi.mocked(analyticsApi);
|
||||
|
||||
const mockOverviewData = {
|
||||
visitors: { current: 12500, previous: 11000, change: 12, trend: 'up' as const },
|
||||
pageviews: { current: 45000, previous: 42000, change: 8, trend: 'up' as const },
|
||||
sessions: { current: 8900, previous: 8500, change: 5, trend: 'up' as const },
|
||||
bounceRate: { current: 35, previous: 38, change: -3, trend: 'down' as const },
|
||||
sessionDuration: { current: 180, previous: 165, change: 10, trend: 'up' as const },
|
||||
conversionRate: { current: 4.5, previous: 4.0, change: 0.5, trend: 'up' as const },
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AnalyticsOverview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading skeleton cards', () => {
|
||||
mockAnalyticsApi.getOverview.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse');
|
||||
expect(skeletonCards.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('shows error message when query fails', async () => {
|
||||
mockAnalyticsApi.getOverview.mockRejectedValue(new Error('Failed to fetch'));
|
||||
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load analytics data. Please try again later.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('success state', () => {
|
||||
beforeEach(() => {
|
||||
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
|
||||
});
|
||||
|
||||
it('renders all metric cards', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unique Visitors')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page Views')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sessions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bounce Rate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Session Duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Conversion Rate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays formatted visitor count', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('12,500')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays formatted page views', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45,000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays formatted bounce rate', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('35%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays formatted session duration', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('3m 0s')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays formatted conversion rate', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4.5%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays change percentage', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('12%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays "from last period" text', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastPeriodTexts = screen.getAllByText('from last period');
|
||||
expect(lastPeriodTexts.length).toBe(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls getOverview with correct timeRange', async () => {
|
||||
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
|
||||
|
||||
render(<AnalyticsOverview timeRange="30d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAnalyticsApi.getOverview).toHaveBeenCalledWith('30d');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('trend indicators', () => {
|
||||
beforeEach(() => {
|
||||
mockAnalyticsApi.getOverview.mockResolvedValue(mockOverviewData);
|
||||
});
|
||||
|
||||
it('shows up arrow for upward trend', async () => {
|
||||
render(<AnalyticsOverview timeRange="7d" />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unique Visitors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const upArrows = document.querySelectorAll('.text-green-500');
|
||||
expect(upArrows.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
interface ContentAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
export declare function ContentAnalytics({ timeRange: _timeRange }: ContentAnalyticsProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,10 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
import {
|
||||
FileText,
|
||||
Eye,
|
||||
MousePointer,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
BookOpen,
|
||||
@@ -17,7 +15,7 @@ interface ContentAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
|
||||
export function ContentAnalytics({ timeRange }: ContentAnalyticsProps) {
|
||||
export function ContentAnalytics({ timeRange: _timeRange }: ContentAnalyticsProps) {
|
||||
// Mock data - in real implementation, this would come from Umami API
|
||||
const contentData = {
|
||||
topPages: [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
interface CustomMetricsDashboardProps {
|
||||
projectId?: string;
|
||||
timeRange: string;
|
||||
}
|
||||
export declare function CustomMetricsDashboard({ projectId, timeRange }: CustomMetricsDashboardProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -4,10 +4,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
import {
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
@@ -15,14 +14,12 @@ import {
|
||||
CheckCircle,
|
||||
Activity,
|
||||
Zap,
|
||||
Server,
|
||||
MemoryStick,
|
||||
Network,
|
||||
Timer,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '@/lib/api';
|
||||
|
||||
interface CustomMetricsDashboardProps {
|
||||
projectId?: string;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export declare function RealTimeAnalytics(): import("react/jsx-runtime").JSX.Element;
|
||||
File diff suppressed because one or more lines are too long
@@ -2,12 +2,11 @@ import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
import {
|
||||
Activity,
|
||||
Users,
|
||||
Eye,
|
||||
MousePointer,
|
||||
Globe,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Clock,
|
||||
@@ -16,12 +15,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
export function RealTimeAnalytics() {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [_currentTime, setCurrentTime] = useState(new Date());
|
||||
const [activeUsers, setActiveUsers] = useState(127);
|
||||
const [currentVisitors, setCurrentVisitors] = useState(34);
|
||||
|
||||
// Mock real-time data - in real implementation, this would update from WebSocket/API
|
||||
const [realTimeData, setRealTimeData] = useState({
|
||||
const [realTimeData, _setRealTimeData] = useState({
|
||||
onlineUsers: 127,
|
||||
currentVisitors: 34,
|
||||
pageviews: [
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
interface TrafficAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
export declare function TrafficAnalytics({ timeRange: _timeRange }: TrafficAnalyticsProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Search, Globe, ExternalLink, MousePointer, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
export function TrafficAnalytics({ timeRange: _timeRange }) {
|
||||
// Mock data - in real implementation, this would come from Umami API
|
||||
const trafficData = {
|
||||
sources: [
|
||||
{
|
||||
name: 'Organic Search',
|
||||
percentage: 35,
|
||||
visitors: 15832,
|
||||
trend: 'up',
|
||||
change: 12.5
|
||||
},
|
||||
{
|
||||
name: 'Direct Traffic',
|
||||
percentage: 28,
|
||||
visitors: 12666,
|
||||
trend: 'up',
|
||||
change: 8.3
|
||||
},
|
||||
{
|
||||
name: 'Social Media',
|
||||
percentage: 18,
|
||||
visitors: 8142,
|
||||
trend: 'down',
|
||||
change: -3.2
|
||||
},
|
||||
{
|
||||
name: 'Referral',
|
||||
percentage: 12,
|
||||
visitors: 5428,
|
||||
trend: 'up',
|
||||
change: 15.7
|
||||
},
|
||||
{
|
||||
name: 'Email Marketing',
|
||||
percentage: 4,
|
||||
visitors: 1809,
|
||||
trend: 'up',
|
||||
change: 22.1
|
||||
},
|
||||
{
|
||||
name: 'Paid Search',
|
||||
percentage: 3,
|
||||
visitors: 1357,
|
||||
trend: 'down',
|
||||
change: -8.9
|
||||
}
|
||||
],
|
||||
referrers: [
|
||||
{ name: 'google.com', visitors: 12456, percentage: 27.5 },
|
||||
{ name: 'github.com', visitors: 8234, percentage: 18.2 },
|
||||
{ name: 'stackoverflow.com', visitors: 5423, percentage: 12.0 },
|
||||
{ name: 'twitter.com', visitors: 3612, percentage: 8.0 },
|
||||
{ name: 'linkedin.com', visitors: 2891, percentage: 6.4 },
|
||||
{ name: 'Others', visitors: 12618, percentage: 27.9 }
|
||||
],
|
||||
campaigns: [
|
||||
{
|
||||
name: 'Summer Launch 2024',
|
||||
visitors: 8234,
|
||||
conversionRate: 4.2,
|
||||
revenue: 12456
|
||||
},
|
||||
{
|
||||
name: 'Product Update',
|
||||
visitors: 5423,
|
||||
conversionRate: 3.8,
|
||||
revenue: 8234
|
||||
},
|
||||
{
|
||||
name: 'Newsletter Signup',
|
||||
visitors: 3612,
|
||||
conversionRate: 2.1,
|
||||
revenue: 2891
|
||||
},
|
||||
{
|
||||
name: 'Social Media Push',
|
||||
visitors: 2891,
|
||||
conversionRate: 1.8,
|
||||
revenue: 1567
|
||||
}
|
||||
],
|
||||
keywords: [
|
||||
{ name: 'container orchestration', visitors: 3421, percentage: 12.3 },
|
||||
{ name: 'paas platform', visitors: 2891, percentage: 10.4 },
|
||||
{ name: 'docker deployment', visitors: 2456, percentage: 8.8 },
|
||||
{ name: 'self-hosted analytics', visitors: 1987, percentage: 7.1 },
|
||||
{ name: 'railway alternative', visitors: 1654, percentage: 5.9 }
|
||||
]
|
||||
};
|
||||
const getTrendIcon = (trend) => {
|
||||
return trend === 'up' ? (_jsx(ArrowUp, { className: "w-3 h-3 text-green-500" })) : (_jsx(ArrowDown, { className: "w-3 h-3 text-red-500" }));
|
||||
};
|
||||
const getSourceIcon = (source) => {
|
||||
if (source.includes('Search'))
|
||||
return _jsx(Search, { className: "w-4 h-4" });
|
||||
if (source.includes('Direct'))
|
||||
return _jsx(MousePointer, { className: "w-4 h-4" });
|
||||
if (source.includes('Social'))
|
||||
return _jsx(Globe, { className: "w-4 h-4" });
|
||||
if (source.includes('Referral'))
|
||||
return _jsx(ExternalLink, { className: "w-4 h-4" });
|
||||
return _jsx(TrendingUp, { className: "w-4 h-4" });
|
||||
};
|
||||
return (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(TrendingUp, { className: "w-5 h-5" }), "Traffic Sources"] }) }), _jsx(CardContent, { className: "space-y-4", children: trafficData.sources.map((source) => (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [getSourceIcon(source.name), _jsx("span", { className: "text-sm font-medium", children: source.name }), _jsxs("div", { className: "flex items-center gap-1", children: [getTrendIcon(source.trend), _jsxs("span", { className: `text-xs ${source.trend === 'up' ? 'text-green-600' : 'text-red-600'}`, children: [Math.abs(source.change), "%"] })] })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [source.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [source.visitors.toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: source.percentage, className: "h-2" })] }, source.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(ExternalLink, { className: "w-5 h-5" }), "Top Referrers"] }) }), _jsx(CardContent, { className: "space-y-3", children: trafficData.referrers.map((referrer) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-blue-500" }), _jsx("span", { className: "text-sm", children: referrer.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [referrer.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [referrer.visitors.toLocaleString(), " visitors"] })] })] }, referrer.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(TrendingUp, { className: "w-5 h-5" }), "Campaign Performance"] }) }), _jsx(CardContent, { className: "space-y-4", children: trafficData.campaigns.map((campaign) => (_jsxs("div", { className: "border rounded-lg p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("h4", { className: "font-medium text-sm", children: campaign.name }), _jsxs(Badge, { variant: "secondary", children: [campaign.conversionRate, "% conversion"] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 text-xs", children: [_jsxs("div", { children: [_jsx("div", { className: "text-muted-foreground", children: "Visitors" }), _jsx("div", { className: "font-semibold", children: campaign.visitors.toLocaleString() })] }), _jsxs("div", { children: [_jsx("div", { className: "text-muted-foreground", children: "Revenue" }), _jsxs("div", { className: "font-semibold", children: ["$", campaign.revenue.toLocaleString()] })] })] })] }, campaign.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Search, { className: "w-5 h-5" }), "Top Search Keywords"] }) }), _jsx(CardContent, { className: "space-y-3", children: trafficData.keywords.map((keyword) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-green-500" }), _jsx("span", { className: "text-sm", children: keyword.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [keyword.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [keyword.visitors.toLocaleString(), " visitors"] })] })] }, keyword.name))) })] })] }));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface TrafficAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
|
||||
export function TrafficAnalytics({ timeRange }: TrafficAnalyticsProps) {
|
||||
export function TrafficAnalytics({ timeRange: _timeRange }: TrafficAnalyticsProps) {
|
||||
// Mock data - in real implementation, this would come from Umami API
|
||||
const trafficData = {
|
||||
sources: [
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
interface VisitorAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
export declare function VisitorAnalytics({ timeRange: _timeRange }: VisitorAnalyticsProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Users, Globe, Monitor, Smartphone, Tablet, MapPin } from 'lucide-react';
|
||||
export function VisitorAnalytics({ timeRange: _timeRange }) {
|
||||
// Mock data - in real implementation, this would come from Umami API
|
||||
const visitorData = {
|
||||
newVsReturning: {
|
||||
new: 68,
|
||||
returning: 32
|
||||
},
|
||||
devices: {
|
||||
desktop: 45,
|
||||
mobile: 42,
|
||||
tablet: 13
|
||||
},
|
||||
browsers: [
|
||||
{ name: 'Chrome', percentage: 45, users: 20356 },
|
||||
{ name: 'Safari', percentage: 28, users: 12666 },
|
||||
{ name: 'Firefox', percentage: 12, users: 5428 },
|
||||
{ name: 'Edge', percentage: 8, users: 3619 },
|
||||
{ name: 'Others', percentage: 7, users: 3166 }
|
||||
],
|
||||
operatingSystems: [
|
||||
{ name: 'Windows', percentage: 38, users: 17189 },
|
||||
{ name: 'macOS', percentage: 32, users: 14475 },
|
||||
{ name: 'Android', percentage: 18, users: 8142 },
|
||||
{ name: 'iOS', percentage: 10, users: 4523 },
|
||||
{ name: 'Linux', percentage: 2, users: 905 }
|
||||
],
|
||||
countries: [
|
||||
{ name: 'United States', percentage: 35, users: 15832 },
|
||||
{ name: 'United Kingdom', percentage: 18, users: 8142 },
|
||||
{ name: 'Germany', percentage: 12, users: 5428 },
|
||||
{ name: 'Canada', percentage: 8, users: 3619 },
|
||||
{ name: 'France', percentage: 7, users: 3166 },
|
||||
{ name: 'Others', percentage: 20, users: 9047 }
|
||||
],
|
||||
languages: [
|
||||
{ name: 'English', percentage: 45, users: 20356 },
|
||||
{ name: 'German', percentage: 15, users: 6785 },
|
||||
{ name: 'French', percentage: 12, users: 5428 },
|
||||
{ name: 'Spanish', percentage: 10, users: 4523 },
|
||||
{ name: 'Others', percentage: 18, users: 8142 }
|
||||
]
|
||||
};
|
||||
const getDeviceIcon = (device) => {
|
||||
switch (device) {
|
||||
case 'desktop':
|
||||
return _jsx(Monitor, { className: "w-4 h-4" });
|
||||
case 'mobile':
|
||||
return _jsx(Smartphone, { className: "w-4 h-4" });
|
||||
case 'tablet':
|
||||
return _jsx(Tablet, { className: "w-4 h-4" });
|
||||
default:
|
||||
return _jsx(Monitor, { className: "w-4 h-4" });
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Users, { className: "w-5 h-5" }), "New vs Returning Visitors"] }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "secondary", children: "New" }), _jsx("span", { className: "text-sm", children: "First-time visitors" })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [visitorData.newVsReturning.new, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [(45234 * visitorData.newVsReturning.new / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: visitorData.newVsReturning.new, className: "h-2" })] }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: "Returning" }), _jsx("span", { className: "text-sm", children: "Repeat visitors" })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [visitorData.newVsReturning.returning, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [(45234 * visitorData.newVsReturning.returning / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: visitorData.newVsReturning.returning, className: "h-2" })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Monitor, { className: "w-5 h-5" }), "Device Breakdown"] }) }), _jsx(CardContent, { className: "space-y-4", children: Object.entries(visitorData.devices).map(([device, percentage]) => (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [getDeviceIcon(device), _jsx("span", { className: "text-sm capitalize", children: device })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [Math.floor(45234 * percentage / 100).toLocaleString(), " visitors"] })] })] }), _jsx(Progress, { value: percentage, className: "h-2" })] }, device))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(Globe, { className: "w-5 h-5" }), "Top Browsers"] }) }), _jsx(CardContent, { className: "space-y-3", children: visitorData.browsers.map((browser) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-blue-500" }), _jsx("span", { className: "text-sm", children: browser.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [browser.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [browser.users.toLocaleString(), " users"] })] })] }, browser.name))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center gap-2", children: [_jsx(MapPin, { className: "w-5 h-5" }), "Top Countries"] }) }), _jsx(CardContent, { className: "space-y-3", children: visitorData.countries.map((country) => (_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-3 h-3 rounded-full bg-green-500" }), _jsx("span", { className: "text-sm", children: country.name })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "font-semibold", children: [country.percentage, "%"] }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [country.users.toLocaleString(), " visitors"] })] })] }, country.name))) })] })] }));
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Users,
|
||||
import {
|
||||
Users,
|
||||
Globe,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -16,7 +14,7 @@ interface VisitorAnalyticsProps {
|
||||
timeRange: string;
|
||||
}
|
||||
|
||||
export function VisitorAnalytics({ timeRange }: VisitorAnalyticsProps) {
|
||||
export function VisitorAnalytics({ timeRange: _timeRange }: VisitorAnalyticsProps) {
|
||||
// Mock data - in real implementation, this would come from Umami API
|
||||
const visitorData = {
|
||||
newVsReturning: {
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
DollarSign,
|
||||
MoreHorizontal,
|
||||
Target,
|
||||
Calendar,
|
||||
Eye,
|
||||
MousePointer,
|
||||
ShoppingCart,
|
||||
Activity,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'completed' | 'paused' | 'draft';
|
||||
reach: number;
|
||||
engagement: number;
|
||||
conversions: number;
|
||||
revenue: number;
|
||||
trend: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
budget: number;
|
||||
spent: number;
|
||||
ctr: number; // Click-through rate
|
||||
cpc: number; // Cost per click
|
||||
platform: 'google' | 'facebook' | 'instagram' | 'email' | 'linkedin';
|
||||
}
|
||||
|
||||
const campaignData: Campaign[] = [
|
||||
{
|
||||
id: 'CAMP-001',
|
||||
name: 'Summer Launch 2024',
|
||||
status: 'active',
|
||||
reach: 45234,
|
||||
engagement: 68,
|
||||
conversions: 892,
|
||||
revenue: 12450,
|
||||
trend: '+15%',
|
||||
startDate: '2024-06-01',
|
||||
endDate: '2024-08-31',
|
||||
budget: 15000,
|
||||
spent: 8750,
|
||||
ctr: 2.8,
|
||||
cpc: 0.45,
|
||||
platform: 'google'
|
||||
},
|
||||
{
|
||||
id: 'CAMP-002',
|
||||
name: 'Product Demo Series',
|
||||
status: 'completed',
|
||||
reach: 28901,
|
||||
engagement: 72,
|
||||
conversions: 456,
|
||||
revenue: 8900,
|
||||
trend: '+8%',
|
||||
startDate: '2024-05-15',
|
||||
endDate: '2024-06-15',
|
||||
budget: 8000,
|
||||
spent: 7200,
|
||||
ctr: 3.2,
|
||||
cpc: 0.38,
|
||||
platform: 'facebook'
|
||||
},
|
||||
{
|
||||
id: 'CAMP-003',
|
||||
name: 'Newsletter Campaign',
|
||||
status: 'active',
|
||||
reach: 18923,
|
||||
engagement: 54,
|
||||
conversions: 234,
|
||||
revenue: 3450,
|
||||
trend: '-2%',
|
||||
startDate: '2024-07-01',
|
||||
budget: 5000,
|
||||
spent: 2100,
|
||||
ctr: 1.8,
|
||||
cpc: 0.12,
|
||||
platform: 'email'
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Active</Badge>;
|
||||
case 'completed':
|
||||
return <Badge variant="secondary">Completed</Badge>;
|
||||
case 'paused':
|
||||
return <Badge variant="outline">Paused</Badge>;
|
||||
case 'draft':
|
||||
return <Badge variant="outline">Draft</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'google':
|
||||
return <Target className="w-4 h-4 text-blue-600" />;
|
||||
case 'facebook':
|
||||
return <Users className="w-4 h-4 text-blue-500" />;
|
||||
case 'instagram':
|
||||
return <Eye className="w-4 h-4 text-pink-600" />;
|
||||
case 'email':
|
||||
return <Activity className="w-4 h-4 text-orange-600" />;
|
||||
case 'linkedin':
|
||||
return <Users className="w-4 h-4 text-blue-700" />;
|
||||
default:
|
||||
return <Target className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
if (trend.startsWith('+')) {
|
||||
return <TrendingUp className="w-3 h-3 text-green-600" />;
|
||||
} else if (trend.startsWith('-')) {
|
||||
return <TrendingDown className="w-3 h-3 text-red-600" />;
|
||||
}
|
||||
return <Activity className="w-3 h-3 text-gray-600" />;
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: string) => {
|
||||
if (trend.startsWith('+')) {
|
||||
return 'text-green-600 bg-green-50';
|
||||
} else if (trend.startsWith('-')) {
|
||||
return 'text-red-600 bg-red-50';
|
||||
}
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
};
|
||||
|
||||
export function CampaignDataCard() {
|
||||
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
|
||||
|
||||
const totalRevenue = campaignData.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const totalConversions = campaignData.reduce((sum, item) => sum + item.conversions, 0);
|
||||
const totalBudget = campaignData.reduce((sum, item) => sum + item.budget, 0);
|
||||
const totalSpent = campaignData.reduce((sum, item) => sum + item.spent, 0);
|
||||
const avgEngagement = Math.round(campaignData.reduce((sum, item) => sum + item.engagement, 0) / campaignData.length);
|
||||
const activeCampaigns = campaignData.filter(c => c.status === 'active').length;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">Campaign Data</CardTitle>
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Key Metrics Overview */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Total Revenue</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold">${totalRevenue.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<TrendingUp className="w-3 h-3 text-green-600" />
|
||||
<span className="text-green-600">+18% vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ShoppingCart className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Conversions</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold">{totalConversions.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<TrendingUp className="w-3 h-3 text-green-600" />
|
||||
<span className="text-green-600">+12% vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Utilization */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Budget Utilization</span>
|
||||
<span className="font-medium">${totalSpent.toLocaleString()} / ${totalBudget.toLocaleString()}</span>
|
||||
</div>
|
||||
<Progress value={(totalSpent / totalBudget) * 100} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{Math.round((totalSpent / totalBudget) * 100)}% spent</span>
|
||||
<span>${(totalBudget - totalSpent).toLocaleString()} remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">{avgEngagement}%</div>
|
||||
<div className="text-xs text-muted-foreground">Avg Engagement</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">{activeCampaigns}</div>
|
||||
<div className="text-xs text-muted-foreground">Active</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">2.6%</div>
|
||||
<div className="text-xs text-muted-foreground">Avg CTR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Active Campaigns</div>
|
||||
<div className="space-y-2">
|
||||
{campaignData.filter(c => c.status === 'active').slice(0, 3).map((campaign) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedCampaign === campaign.id
|
||||
? 'bg-primary/10 border-primary/30'
|
||||
: 'bg-muted/30 border-border hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => setSelectedCampaign(campaign.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getPlatformIcon(campaign.platform)}
|
||||
<span className="text-sm font-medium truncate">{campaign.name}</span>
|
||||
{getStatusBadge(campaign.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Reach</div>
|
||||
<div className="font-medium">{campaign.reach.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Revenue</div>
|
||||
<div className="font-medium">${campaign.revenue.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{campaign.ctr}% CTR</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointer className="w-3 h-3 text-muted-foreground" />
|
||||
<span>${campaign.cpc} CPC</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-xs ${getTrendColor(campaign.trend)}`}>
|
||||
{getTrendIcon(campaign.trend)}
|
||||
{campaign.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Timeline */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Recent Activity</div>
|
||||
<div className="space-y-2">
|
||||
{campaignData.slice(0, 2).map((campaign) => (
|
||||
<div key={campaign.id} className="flex items-center gap-3 text-xs">
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
|
||||
<Calendar className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{campaign.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{campaign.status === 'active' ? 'Started' : 'Completed'} {campaign.startDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{campaign.status === 'active' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Active</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>Ended {campaign.endDate}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs">
|
||||
View All Campaigns
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 text-xs">
|
||||
Create Campaign
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ConversionRateCard() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const conversionData = {
|
||||
'1D': {
|
||||
rate: '15.2%',
|
||||
trend: '+0.8%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 384, trend: '+3%' },
|
||||
checkout: { count: 215, trend: '+2%' },
|
||||
payment: { count: 184, trend: '+1%' }
|
||||
}
|
||||
},
|
||||
'1W': {
|
||||
rate: '16.9%',
|
||||
trend: '+2.1%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 3842, trend: '+12%' },
|
||||
checkout: { count: 2156, trend: '+8%' },
|
||||
payment: { count: 1842, trend: '+5%' }
|
||||
}
|
||||
},
|
||||
'1M': {
|
||||
rate: '18.3%',
|
||||
trend: '+3.4%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 16547, trend: '+18%' },
|
||||
checkout: { count: 9234, trend: '+14%' },
|
||||
payment: { count: 7892, trend: '+11%' }
|
||||
}
|
||||
},
|
||||
'3M': {
|
||||
rate: '19.7%',
|
||||
trend: '+4.8%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 52341, trend: '+28%' },
|
||||
checkout: { count: 29456, trend: '+22%' },
|
||||
payment: { count: 25123, trend: '+19%' }
|
||||
}
|
||||
},
|
||||
'6M': {
|
||||
rate: '21.2%',
|
||||
trend: '+6.3%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 108934, trend: '+41%' },
|
||||
checkout: { count: 61234, trend: '+35%' },
|
||||
payment: { count: 52345, trend: '+31%' }
|
||||
}
|
||||
},
|
||||
'1Y': {
|
||||
rate: '23.8%',
|
||||
trend: '+8.9%',
|
||||
direction: 'up' as 'up',
|
||||
funnel: {
|
||||
cart: { count: 224567, trend: '+67%' },
|
||||
checkout: { count: 126789, trend: '+58%' },
|
||||
payment: { count: 108234, trend: '+52%' }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = conversionData[selectedPeriod as keyof typeof conversionData];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title="Conversion Rate"
|
||||
value={currentData.rate}
|
||||
trend={{
|
||||
value: currentData.trend,
|
||||
direction: currentData.direction
|
||||
}}
|
||||
actionLabel="Details"
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
>
|
||||
<div className="w-full flex-col gap-3">
|
||||
{/* Conversion Funnel */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 text-sm text-muted-foreground font-medium">Added to Cart</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.cart.count.toLocaleString()}</div>
|
||||
<Badge
|
||||
variant={currentData.funnel.cart.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.funnel.cart.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.funnel.cart.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 text-sm text-muted-foreground font-medium">Checkout Started</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.checkout.count.toLocaleString()}</div>
|
||||
<Badge
|
||||
variant={currentData.funnel.checkout.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.funnel.checkout.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.funnel.checkout.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 text-sm text-muted-foreground font-medium">Payment Completed</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.payment.count.toLocaleString()}</div>
|
||||
<Badge
|
||||
variant={currentData.funnel.payment.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.funnel.payment.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.funnel.payment.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Sparkline Visualization */}
|
||||
<div className="mt-4 pt-3 border-t border-border/20">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-xs">Conversion trend</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-8 w-full bg-muted/20 rounded-sm flex items-end justify-between gap-1 px-1">
|
||||
{[65, 72, 68, 75, 82, 79, 85, 88, 92, 87, 91, 95].map((height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-primary/60 rounded-sm transition-all duration-300 hover:bg-primary/80"
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, TrendingDown, FileText, BarChart3 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
function MetricCard({ title, value, trend, timePeriods = ['1D', '1W', '1M', '3M', '6M', '1Y'], actionLabel = 'Report', children, selectedPeriod = '1W', onPeriodChange }) {
|
||||
const [currentPeriod, setCurrentPeriod] = useState(selectedPeriod);
|
||||
const handlePeriodChange = (period) => {
|
||||
setCurrentPeriod(period);
|
||||
onPeriodChange?.(period);
|
||||
};
|
||||
return (_jsx(Card, { className: "w-full shadow-sm border-0 ring-1 ring-inset ring-border/20", children: _jsxs(CardContent, { className: "p-5 space-y-5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { className: "flex-1", children: [_jsx("div", { className: "text-sm text-muted-foreground font-medium", children: title }), _jsxs("div", { className: "mt-1 flex items-center gap-2", children: [_jsx("div", { className: "text-2xl font-bold text-foreground", children: value }), trend && (_jsxs(Badge, { variant: trend.direction === 'up' ? 'default' : 'destructive', className: `h-5 gap-1.5 px-2 text-xs font-medium ${trend.direction === 'up'
|
||||
? 'bg-green-100 text-green-800 border-green-200'
|
||||
: 'bg-red-100 text-red-800 border-red-200'}`, children: [trend.direction === 'up' ? (_jsx(TrendingUp, { className: "w-3 h-3" })) : (_jsx(TrendingDown, { className: "w-3 h-3" })), trend.value] }))] })] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors", children: [actionLabel === 'Report' ? _jsx(FileText, { className: "w-3 h-3" }) : _jsx(BarChart3, { className: "w-3 h-3" }), actionLabel] })] }), children && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-full h-px bg-border/20" }), children] })), timePeriods && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-full h-px bg-border/20" }), _jsx("div", { className: "flex gap-0.5", role: "radiogroup", children: timePeriods.map((period) => (_jsx(Button, { variant: currentPeriod === period ? 'default' : 'ghost', size: "sm", onClick: () => handlePeriodChange(period), className: `h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${currentPeriod === period
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted/50 text-muted-foreground'}`, children: period }, period))) })] }))] }) }));
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface MetricCardProps {
|
||||
onPeriodChange?: (period: string) => void;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
function _MetricCard({
|
||||
title,
|
||||
value,
|
||||
trend,
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
MoreHorizontal,
|
||||
Box,
|
||||
Star,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Minus,
|
||||
Activity,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProductCategory {
|
||||
name: string;
|
||||
value: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
growth: string;
|
||||
status: 'up' | 'down' | 'neutral';
|
||||
products: number;
|
||||
avgPrice: number;
|
||||
topSeller?: string;
|
||||
}
|
||||
|
||||
const categoryData: ProductCategory[] = [
|
||||
{
|
||||
name: 'Premium',
|
||||
value: 6450,
|
||||
percentage: 36,
|
||||
color: '#f59e0b',
|
||||
growth: '+12%',
|
||||
status: 'up',
|
||||
products: 145,
|
||||
avgPrice: 44.48,
|
||||
topSeller: 'Premium Suite'
|
||||
},
|
||||
{
|
||||
name: 'Regular',
|
||||
value: 5320,
|
||||
percentage: 30,
|
||||
color: '#6b7280',
|
||||
growth: '+5%',
|
||||
status: 'up',
|
||||
products: 289,
|
||||
avgPrice: 18.41,
|
||||
topSeller: 'Standard Pack'
|
||||
},
|
||||
{
|
||||
name: 'New',
|
||||
value: 3280,
|
||||
percentage: 18,
|
||||
color: '#10b981',
|
||||
growth: '+28%',
|
||||
status: 'up',
|
||||
products: 67,
|
||||
avgPrice: 48.96,
|
||||
topSeller: 'Starter Kit'
|
||||
},
|
||||
{
|
||||
name: 'Others',
|
||||
value: 2850,
|
||||
percentage: 16,
|
||||
color: '#e5e7eb',
|
||||
growth: '-3%',
|
||||
status: 'down',
|
||||
products: 103,
|
||||
avgPrice: 27.67,
|
||||
topSeller: 'Misc Items'
|
||||
}
|
||||
];
|
||||
|
||||
const monthlySalesData = [
|
||||
{ month: 'Jan', Premium: 5200, Regular: 4800, New: 2100, Others: 2900 },
|
||||
{ month: 'Feb', Premium: 5400, Regular: 4900, New: 2400, Others: 2800 },
|
||||
{ month: 'Mar', Premium: 5800, Regular: 5100, New: 2800, Others: 2700 },
|
||||
{ month: 'Apr', Premium: 6200, Regular: 5300, New: 3200, Others: 2900 },
|
||||
{ month: 'May', Premium: 6450, Regular: 5320, New: 3280, Others: 2850 }
|
||||
];
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-3 border rounded-lg shadow-sm">
|
||||
<p className="font-medium text-sm">{payload[0].name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
${payload[0].value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{payload[0].payload.percentage}%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTrendIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <ArrowUpRight className="w-3 h-3 text-green-600" />;
|
||||
case 'down':
|
||||
return <ArrowDownRight className="w-3 h-3 text-red-600" />;
|
||||
default:
|
||||
return <Minus className="w-3 h-3 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'down':
|
||||
return 'text-red-600 bg-red-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
export function ProductCategoriesCard() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const totalRevenue = categoryData.reduce((sum, item) => sum + item.value, 0);
|
||||
const totalProducts = categoryData.reduce((sum, item) => sum + item.products, 0);
|
||||
const avgGrowth = '+10.5%';
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">Product Categories</CardTitle>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Revenue Overview */}
|
||||
<div className="text-center p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<DollarSign className="w-5 h-5 text-green-600" />
|
||||
<div className="text-2xl font-bold">${totalRevenue.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total Revenue by Category</div>
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-600">{avgGrowth} vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pie Chart */}
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
className="hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onClick={() => setSelectedCategory(entry.name)}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Category Performance</div>
|
||||
<div className="space-y-2">
|
||||
{categoryData.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedCategory === category.name
|
||||
? 'bg-primary/10 border-primary/30'
|
||||
: 'bg-muted/30 border-border hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => setSelectedCategory(category.name)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{category.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Box className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{category.products}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-xs ${getTrendColor(category.status)}`}>
|
||||
{getTrendIcon(category.status)}
|
||||
{category.growth}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Revenue</div>
|
||||
<div className="font-medium">${category.value.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Avg Price</div>
|
||||
<div className="font-medium">${category.avgPrice}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{category.topSeller && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Star className="w-3 h-3 text-yellow-500" />
|
||||
<span>Top: {category.topSeller}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Sales Trend */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">5-Month Trend</div>
|
||||
<div className="h-32">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlySalesData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: '12px', padding: '4px' }}
|
||||
labelStyle={{ fontSize: '10px' }}
|
||||
/>
|
||||
<Bar dataKey="Premium" fill="#f59e0b" />
|
||||
<Bar dataKey="Regular" fill="#6b7280" />
|
||||
<Bar dataKey="New" fill="#10b981" />
|
||||
<Bar dataKey="Others" fill="#e5e7eb" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">{totalProducts}</div>
|
||||
<div className="text-xs text-muted-foreground">Products</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">${Math.round(totalRevenue / totalProducts)}</div>
|
||||
<div className="text-xs text-muted-foreground">Avg Price</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-sm font-bold">4</div>
|
||||
<div className="text-xs text-muted-foreground">Categories</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs">
|
||||
<ShoppingCart className="w-3 h-3 mr-1" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 text-xs">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function ProjectCanvas(): import("react/jsx-runtime").JSX.Element;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,167 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
GitBranch,
|
||||
Database,
|
||||
Settings,
|
||||
UserPlus,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
export function RecentActivitiesCard() {
|
||||
const activities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'deployment',
|
||||
title: 'web-app deployed successfully',
|
||||
description: 'Version 2.1.0 deployed to production',
|
||||
time: '2 minutes ago',
|
||||
status: 'success',
|
||||
icon: GitBranch
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'database',
|
||||
title: 'Database backup completed',
|
||||
description: 'PostgreSQL backup automated',
|
||||
time: '15 minutes ago',
|
||||
status: 'success',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'settings',
|
||||
title: 'Configuration updated',
|
||||
description: 'Environment variables modified',
|
||||
time: '1 hour ago',
|
||||
status: 'warning',
|
||||
icon: Settings
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'user',
|
||||
title: 'New team member added',
|
||||
description: 'John Doe joined the project',
|
||||
time: '3 hours ago',
|
||||
status: 'success',
|
||||
icon: UserPlus
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'alert',
|
||||
title: 'High memory usage detected',
|
||||
description: 'Node-3 memory usage at 85%',
|
||||
time: '4 hours ago',
|
||||
status: 'error',
|
||||
icon: AlertTriangle
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'deployment',
|
||||
title: 'API server restarted',
|
||||
description: 'Automatic restart after crash',
|
||||
time: '6 hours ago',
|
||||
status: 'success',
|
||||
icon: GitBranch
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: 'deployment',
|
||||
title: 'Worker service updated',
|
||||
description: 'Background tasks service patched',
|
||||
time: '8 hours ago',
|
||||
status: 'success',
|
||||
icon: GitBranch
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'warning':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case 'error':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Badge variant="default" className="bg-green-100 text-green-800">Success</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="secondary">Warning</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="destructive">Error</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">Info</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<CardTitle>Recent Activities</CardTitle>
|
||||
<CardDescription>7 new activities today</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2">
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Time Filter */}
|
||||
<div className="flex flex-wrap gap-2.5" role="radiogroup">
|
||||
{['Today', 'Yesterday', 'This Week', 'This Month'].map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
variant={period === 'Today' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-sm"
|
||||
>
|
||||
{period}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activities List */}
|
||||
<div className="space-y-3">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<activity.icon className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
{activity.title}
|
||||
</h4>
|
||||
{getStatusIcon(activity.status)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{activity.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activity.time}
|
||||
</span>
|
||||
{getStatusBadge(activity.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function SalesMetricCard() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const salesData = {
|
||||
'1D': { value: '$128.32', trend: '+2%', direction: 'up' as const },
|
||||
'1W': { value: '$897.24', trend: '+5.2%', direction: 'up' as const },
|
||||
'1M': { value: '$3,847.92', trend: '+12.8%', direction: 'up' as const },
|
||||
'3M': { value: '$11,543.76', trend: '+18.3%', direction: 'up' as const },
|
||||
'6M': { value: '$23,087.52', trend: '+24.1%', direction: 'up' as const },
|
||||
'1Y': { value: '$46,175.04', trend: '+31.7%', direction: 'up' as const },
|
||||
};
|
||||
|
||||
const currentData = salesData[selectedPeriod as keyof typeof salesData];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title="Total Sales"
|
||||
value={currentData.value}
|
||||
trend={{
|
||||
value: currentData.trend,
|
||||
direction: currentData.direction
|
||||
}}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, Clock, Package, Truck } from 'lucide-react';
|
||||
|
||||
export function ShippingTrackingCard() {
|
||||
const trackingSteps = [
|
||||
{ icon: Package, label: 'Order Placed', time: 'Dec 10, 2:30 PM', completed: true },
|
||||
{ icon: CheckCircle, label: 'Processing', time: 'Dec 10, 4:15 PM', completed: true },
|
||||
{ icon: Truck, label: 'Shipped', time: 'Dec 11, 10:00 AM', completed: true },
|
||||
{ icon: Clock, label: 'Out for Delivery', time: 'Dec 12, 8:00 AM', completed: false },
|
||||
{ icon: CheckCircle, label: 'Delivered', time: 'Expected by 6:00 PM', completed: false }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-sm text-muted-foreground">Shipping Tracking</div>
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold">Order #12345</div>
|
||||
</div>
|
||||
<Badge variant="secondary">In Transit</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tracking Steps */}
|
||||
<div className="space-y-4">
|
||||
{trackingSteps.map((step, index) => (
|
||||
<div key={step.label} className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<step.icon
|
||||
className={`w-5 h-5 ${
|
||||
step.completed ? 'text-green-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium ${
|
||||
step.completed ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{step.time}</div>
|
||||
</div>
|
||||
{index < trackingSteps.length - 1 && (
|
||||
<div className={`w-px h-8 ${
|
||||
index < 2 ? 'bg-green-600' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
MoreHorizontal,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Users,
|
||||
Package,
|
||||
Activity,
|
||||
Phone,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'open' | 'in_progress' | 'resolved';
|
||||
assignee?: string;
|
||||
createdAt: string;
|
||||
category: 'bug' | 'feature' | 'support' | 'incident';
|
||||
}
|
||||
|
||||
const mockTickets: Ticket[] = [
|
||||
{
|
||||
id: 'TK-001',
|
||||
subject: 'Database connection timeout in production',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
assignee: 'Sarah Chen',
|
||||
createdAt: '2 hours ago',
|
||||
category: 'incident'
|
||||
},
|
||||
{
|
||||
id: 'TK-002',
|
||||
subject: 'Add custom domain support',
|
||||
priority: 'medium',
|
||||
status: 'open',
|
||||
assignee: 'Mike Johnson',
|
||||
createdAt: '5 hours ago',
|
||||
category: 'feature'
|
||||
},
|
||||
{
|
||||
id: 'TK-003',
|
||||
subject: 'Deployment logs not showing for Node.js apps',
|
||||
priority: 'medium',
|
||||
status: 'resolved',
|
||||
assignee: 'Alex Kumar',
|
||||
createdAt: '1 day ago',
|
||||
category: 'bug'
|
||||
}
|
||||
];
|
||||
|
||||
const supportData = [
|
||||
{ channel: 'Email', tickets: 245, trend: '+12%', status: 'up', icon: Mail },
|
||||
{ channel: 'Chat', tickets: 189, trend: '+8%', status: 'up', icon: MessageSquare },
|
||||
{ channel: 'Phone', tickets: 67, trend: '-3%', status: 'down', icon: Phone },
|
||||
{ channel: 'Social', tickets: 34, trend: '0%', status: 'neutral', icon: Share2 },
|
||||
];
|
||||
|
||||
const getTrendIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-4 h-4 text-green-600" />;
|
||||
case 'down':
|
||||
return <TrendingDown className="w-4 h-4 text-red-600" />;
|
||||
default:
|
||||
return <Minus className="w-4 h-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'down':
|
||||
return 'text-red-600 bg-red-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return <Clock className="w-3 h-3" />;
|
||||
case 'in_progress':
|
||||
return <Activity className="w-3 h-3" />;
|
||||
case 'resolved':
|
||||
return <CheckCircle className="w-3 h-3" />;
|
||||
default:
|
||||
return <Clock className="w-3 h-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'incident':
|
||||
return <AlertTriangle className="w-3 h-3 text-red-600" />;
|
||||
case 'bug':
|
||||
return <AlertTriangle className="w-3 h-3 text-orange-600" />;
|
||||
case 'feature':
|
||||
return <Package className="w-3 h-3 text-blue-600" />;
|
||||
case 'support':
|
||||
return <Users className="w-3 h-3 text-green-600" />;
|
||||
default:
|
||||
return <AlertTriangle className="w-3 h-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
export function SupportAnalyticsCard() {
|
||||
const [selectedTicket, setSelectedTicket] = useState<string | null>(null);
|
||||
|
||||
const openTickets = mockTickets.filter(t => t.status !== 'resolved').length;
|
||||
const highPriorityTickets = mockTickets.filter(t => t.priority === 'high' && t.status !== 'resolved').length;
|
||||
const avgResolutionTime = '4.2 hours';
|
||||
const satisfactionRate = '94%';
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">Support Analytics</CardTitle>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Metrics Overview */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Open Tickets</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold">{openTickets}</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<TrendingDown className="w-3 h-3 text-green-600" />
|
||||
<span className="text-green-600">-12% from last week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">High Priority</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-600">{highPriorityTickets}</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<TrendingUp className="w-3 h-3 text-red-600" />
|
||||
<span className="text-red-600">+2 new today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Breakdown */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Support Channels</div>
|
||||
{supportData.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
return (
|
||||
<div key={channel.channel} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{channel.channel}</div>
|
||||
<div className="text-xs text-muted-foreground">{channel.tickets} tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={getTrendColor(channel.status)}>
|
||||
{getTrendIcon(channel.status)}
|
||||
{channel.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Avg Resolution Time</span>
|
||||
<span className="font-medium">{avgResolutionTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Customer Satisfaction</span>
|
||||
<span className="font-medium text-green-600">{satisfactionRate}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Response Rate</span>
|
||||
<span className="font-medium">87%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Tickets */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Recent Activity</div>
|
||||
<div className="space-y-2">
|
||||
{mockTickets.slice(0, 3).map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedTicket === ticket.id
|
||||
? 'bg-primary/10 border-primary/30'
|
||||
: 'bg-muted/30 border-border hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => setSelectedTicket(ticket.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getCategoryIcon(ticket.category)}
|
||||
<span className="text-xs font-medium truncate">{ticket.id}</span>
|
||||
<Badge className={`text-xs px-1.5 py-0.5 ${getPriorityColor(ticket.priority)}`}>
|
||||
{ticket.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate leading-tight">
|
||||
{ticket.subject}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
{getStatusIcon(ticket.status)}
|
||||
<span>{ticket.assignee || 'Unassigned'}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs">
|
||||
View All Tickets
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 text-xs">
|
||||
New Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UserRetentionChart() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const retentionData = {
|
||||
'1D': {
|
||||
rate: 22,
|
||||
trend: '+0.5%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[84, 90, 85, 79, 94, 92, 87, 81],
|
||||
[76, 83, 80, 77, 86, 84, 82, 78],
|
||||
[63, 70, 68, 66, 73, 71, 69, 67],
|
||||
[50, 56, 54, 52, 58, 57, 55, 53],
|
||||
[36, 40, 39, 37, 42, 41, 40, 38],
|
||||
[23, 26, 25, 24, 27, 26, 25, 24],
|
||||
[13, 15, 14, 13, 16, 15, 14, 13],
|
||||
[6, 7, 6, 6, 8, 7, 7, 6]
|
||||
]
|
||||
},
|
||||
'1W': {
|
||||
rate: 24,
|
||||
trend: '+2.0%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[86, 92, 87, 81, 96, 94, 89, 83],
|
||||
[78, 85, 82, 79, 88, 86, 84, 80],
|
||||
[65, 72, 70, 68, 75, 73, 71, 69],
|
||||
[52, 58, 56, 54, 60, 59, 57, 55],
|
||||
[38, 42, 41, 39, 44, 43, 42, 40],
|
||||
[25, 28, 27, 26, 29, 28, 27, 26],
|
||||
[15, 17, 16, 15, 18, 17, 16, 15],
|
||||
[8, 9, 8, 8, 10, 9, 9, 8]
|
||||
]
|
||||
},
|
||||
'1M': {
|
||||
rate: 28,
|
||||
trend: '+3.2%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[88, 94, 89, 83, 98, 96, 91, 85],
|
||||
[80, 87, 84, 81, 90, 88, 86, 82],
|
||||
[67, 74, 72, 70, 77, 75, 73, 71],
|
||||
[54, 60, 58, 56, 62, 61, 59, 57],
|
||||
[40, 44, 43, 41, 46, 45, 44, 42],
|
||||
[27, 30, 29, 28, 31, 30, 29, 28],
|
||||
[17, 19, 18, 17, 20, 19, 18, 17],
|
||||
[10, 11, 10, 10, 12, 11, 11, 10]
|
||||
]
|
||||
},
|
||||
'3M': {
|
||||
rate: 31,
|
||||
trend: '+4.8%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[90, 96, 91, 85, 100, 98, 93, 87],
|
||||
[82, 89, 86, 83, 92, 90, 88, 84],
|
||||
[69, 76, 74, 72, 79, 77, 75, 73],
|
||||
[56, 62, 60, 58, 64, 63, 61, 59],
|
||||
[42, 46, 45, 43, 48, 47, 46, 44],
|
||||
[29, 32, 31, 30, 33, 32, 31, 30],
|
||||
[19, 21, 20, 19, 22, 21, 20, 19],
|
||||
[12, 13, 12, 12, 14, 13, 13, 12]
|
||||
]
|
||||
},
|
||||
'6M': {
|
||||
rate: 35,
|
||||
trend: '+6.1%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[92, 98, 93, 87, 102, 100, 95, 89],
|
||||
[84, 91, 88, 85, 94, 92, 90, 86],
|
||||
[71, 78, 76, 74, 81, 79, 77, 75],
|
||||
[58, 64, 62, 60, 66, 65, 63, 61],
|
||||
[44, 48, 47, 45, 50, 49, 48, 46],
|
||||
[31, 34, 33, 32, 35, 34, 33, 32],
|
||||
[21, 23, 22, 21, 24, 23, 22, 21],
|
||||
[14, 15, 14, 14, 16, 15, 15, 14]
|
||||
]
|
||||
},
|
||||
'1Y': {
|
||||
rate: 41,
|
||||
trend: '+9.2%',
|
||||
direction: 'up' as 'up',
|
||||
weeks: ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8'],
|
||||
data: [
|
||||
[94, 100, 95, 89, 104, 102, 97, 91],
|
||||
[86, 93, 90, 87, 96, 94, 92, 88],
|
||||
[73, 80, 78, 76, 83, 81, 79, 77],
|
||||
[60, 66, 64, 62, 68, 67, 65, 63],
|
||||
[46, 50, 49, 47, 52, 51, 50, 48],
|
||||
[33, 36, 35, 34, 37, 36, 35, 34],
|
||||
[23, 25, 24, 23, 26, 25, 24, 23],
|
||||
[16, 17, 16, 16, 18, 17, 17, 16]
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = retentionData[selectedPeriod as keyof typeof retentionData];
|
||||
|
||||
const getOpacity = (value: number) => (value / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground font-medium">User Retention</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-foreground">{currentData.rate}%</div>
|
||||
<Badge
|
||||
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
|
||||
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
|
||||
currentData.direction === 'up'
|
||||
? 'bg-green-100 text-green-800 border-green-200'
|
||||
: 'bg-red-100 text-red-800 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{currentData.direction === 'up' ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Retention Heatmap */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-[194px] w-full border-collapse"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, hsl(var(--border)) 1px, #0000 1px 100%) 0 0 / 100% calc(152px / 4) no-repeat repeat'
|
||||
}}
|
||||
>
|
||||
<table className="-m-px h-full w-full border-collapse" cellPadding="0">
|
||||
<tbody>
|
||||
{currentData.data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((value, colIndex) => (
|
||||
<td
|
||||
key={`${rowIndex}-${colIndex}`}
|
||||
className="p-px"
|
||||
data-value={value}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full rounded-[1px] bg-primary transition-all duration-200 hover:opacity-100"
|
||||
style={{ opacity: getOpacity(value) }}
|
||||
title={`${currentData.weeks[colIndex]}: ${value}%`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Corner decorations */}
|
||||
<div className="absolute bottom-6 left-0 z-10 size-4 overflow-hidden">
|
||||
<div className="size-4 rounded-bl-lg" style={{ boxShadow: '-100px 100px 0 100px hsl(var(--background))' }} />
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-0 z-10 size-4 overflow-hidden">
|
||||
<div className="size-4 rounded-br-lg" style={{ boxShadow: '100px 100px 0 100px hsl(var(--background))' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="font-medium">Cohort Analysis</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-primary opacity-20 rounded" />
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-primary opacity-60 rounded" />
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-primary rounded" />
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Period Selector */}
|
||||
<div className="pt-3 border-t border-border/20">
|
||||
<div className="flex gap-0.5" role="radiogroup">
|
||||
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
variant={selectedPeriod === period ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
|
||||
selectedPeriod === period
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted/50 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{period}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingDown, TrendingUp, BarChart3 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function VisitorChannelsChart() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const channelsData = {
|
||||
'1D': {
|
||||
overall: 76,
|
||||
trend: '-0.8%',
|
||||
direction: 'down' as 'down',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 43, color: 'bg-gray-500', trend: '-1.2%' },
|
||||
{ name: 'Direct Traffic', percentage: 42, color: 'bg-blue-500', trend: '-0.3%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.7%' }
|
||||
]
|
||||
},
|
||||
'1W': {
|
||||
overall: 78,
|
||||
trend: '-0.4%',
|
||||
direction: 'down' as 'down',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 45, color: 'bg-gray-500', trend: '-0.8%' },
|
||||
{ name: 'Direct Traffic', percentage: 40, color: 'bg-blue-500', trend: '-0.2%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.6%' }
|
||||
]
|
||||
},
|
||||
'1M': {
|
||||
overall: 81,
|
||||
trend: '+1.2%',
|
||||
direction: 'up' as 'up',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 47, color: 'bg-gray-500', trend: '+2.1%' },
|
||||
{ name: 'Direct Traffic', percentage: 38, color: 'bg-blue-500', trend: '+0.9%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+0.6%' }
|
||||
]
|
||||
},
|
||||
'3M': {
|
||||
overall: 84,
|
||||
trend: '+2.8%',
|
||||
direction: 'up' as 'up',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 48, color: 'bg-gray-500', trend: '+4.2%' },
|
||||
{ name: 'Direct Traffic', percentage: 37, color: 'bg-blue-500', trend: '+1.8%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+2.4%' }
|
||||
]
|
||||
},
|
||||
'6M': {
|
||||
overall: 86,
|
||||
trend: '+4.3%',
|
||||
direction: 'up' as 'up',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 49, color: 'bg-gray-500', trend: '+6.7%' },
|
||||
{ name: 'Direct Traffic', percentage: 36, color: 'bg-blue-500', trend: '+3.1%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+3.1%' }
|
||||
]
|
||||
},
|
||||
'1Y': {
|
||||
overall: 89,
|
||||
trend: '+7.1%',
|
||||
direction: 'up' as 'up',
|
||||
channels: [
|
||||
{ name: 'Organic Search', percentage: 51, color: 'bg-gray-500', trend: '+11.3%' },
|
||||
{ name: 'Direct Traffic', percentage: 34, color: 'bg-blue-500', trend: '+5.2%' },
|
||||
{ name: 'Social Media', percentage: 15, color: 'bg-green-500', trend: '+4.8%' }
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = channelsData[selectedPeriod as keyof typeof channelsData];
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-sm text-muted-foreground font-medium">Visitors Channels</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-foreground">{currentData.overall}%</div>
|
||||
<Badge
|
||||
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
|
||||
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
|
||||
currentData.direction === 'up'
|
||||
? 'bg-green-100 text-green-800 border-green-200'
|
||||
: 'bg-red-100 text-red-800 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{currentData.direction === 'up' ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-[5px]">
|
||||
{currentData.channels.map((channel, index) => (
|
||||
<div
|
||||
key={channel.name}
|
||||
className="h-2 rounded-sm transition-all duration-300 hover:opacity-80"
|
||||
style={{ width: `${channel.percentage}%` }}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-sm ${channel.color} chart-category-cell-load`}
|
||||
style={{ '--i': index } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel Labels */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{currentData.channels.map((channel) => (
|
||||
<div key={channel.name} className="flex items-center gap-1 text-left text-xs text-muted-foreground">
|
||||
<div className={`w-3 h-3 shrink-0 rounded-full border-2 border-background shadow-sm ${channel.color}`} />
|
||||
<span className="font-medium">{channel.name}</span>
|
||||
<span className="font-semibold text-foreground">{channel.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Period Selector */}
|
||||
<div className="pt-3 border-t border-border/20">
|
||||
<div className="flex gap-0.5" role="radiogroup">
|
||||
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
variant={selectedPeriod === period ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
|
||||
selectedPeriod === period
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted/50 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{period}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, TrendingDown, Monitor, Smartphone, Tablet } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function VisitorsMetricCard() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const visitorsData = {
|
||||
'1D': {
|
||||
value: '23,746',
|
||||
trend: '-1.4%',
|
||||
direction: 'down' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 27, trend: '-3.2%' },
|
||||
mobile: { percentage: 63, trend: '+0.8%' },
|
||||
tablet: { percentage: 10, trend: '-1.1%' }
|
||||
}
|
||||
},
|
||||
'1W': {
|
||||
value: '237,456',
|
||||
trend: '-1.4%',
|
||||
direction: 'down' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 27, trend: '-3.2%' },
|
||||
mobile: { percentage: 63, trend: '+0.8%' },
|
||||
tablet: { percentage: 10, trend: '-1.1%' }
|
||||
}
|
||||
},
|
||||
'1M': {
|
||||
value: '1,012,847',
|
||||
trend: '+2.1%',
|
||||
direction: 'up' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 25, trend: '-2.1%' },
|
||||
mobile: { percentage: 65, trend: '+3.4%' },
|
||||
tablet: { percentage: 10, trend: '-1.3%' }
|
||||
}
|
||||
},
|
||||
'3M': {
|
||||
value: '3,047,234',
|
||||
trend: '+5.8%',
|
||||
direction: 'up' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 24, trend: '-4.2%' },
|
||||
mobile: { percentage: 66, trend: '+7.1%' },
|
||||
tablet: { percentage: 10, trend: '-2.9%' }
|
||||
}
|
||||
},
|
||||
'6M': {
|
||||
value: '6,234,891',
|
||||
trend: '+8.3%',
|
||||
direction: 'up' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 23, trend: '-5.8%' },
|
||||
mobile: { percentage: 67, trend: '+11.2%' },
|
||||
tablet: { percentage: 10, trend: '-5.4%' }
|
||||
}
|
||||
},
|
||||
'1Y': {
|
||||
value: '12,891,234',
|
||||
trend: '+12.7%',
|
||||
direction: 'up' as const,
|
||||
devices: {
|
||||
desktop: { percentage: 22, trend: '-8.1%' },
|
||||
mobile: { percentage: 68, trend: '+18.3%' },
|
||||
tablet: { percentage: 10, trend: '-10.2%' }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = visitorsData[selectedPeriod as keyof typeof visitorsData];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title="Total Visitors"
|
||||
value={currentData.value}
|
||||
trend={{
|
||||
value: currentData.trend,
|
||||
direction: currentData.direction
|
||||
}}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Device Breakdown */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Desktop</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{currentData.devices.desktop.percentage}%</span>
|
||||
<Badge
|
||||
variant={currentData.devices.desktop.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.devices.desktop.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.devices.desktop.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Mobile</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{currentData.devices.mobile.percentage}%</span>
|
||||
<Badge
|
||||
variant={currentData.devices.mobile.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.devices.mobile.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.devices.mobile.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tablet className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Tablet</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{currentData.devices.tablet.percentage}%</span>
|
||||
<Badge
|
||||
variant={currentData.devices.tablet.trend.startsWith('+') ? 'default' : 'destructive'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
>
|
||||
{currentData.devices.tablet.trend.startsWith('+') ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.devices.tablet.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Progress Bars */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${currentData.devices.desktop.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${currentData.devices.mobile.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 transition-all duration-300"
|
||||
style={{ width: `${currentData.devices.tablet.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function WeeklyVisitorsChart() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('1W');
|
||||
|
||||
const weeklyData = {
|
||||
'1D': {
|
||||
total: 15847,
|
||||
trend: '+0.3%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 9508,
|
||||
returningVisitors: 6339,
|
||||
data: {
|
||||
new: [1200, 1350, 1100, 1400, 1300, 1250, 1400],
|
||||
returning: [800, 900, 850, 950, 900, 880, 950]
|
||||
}
|
||||
},
|
||||
'1W': {
|
||||
total: 16008,
|
||||
trend: '+1.1%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 9605,
|
||||
returningVisitors: 6403,
|
||||
data: {
|
||||
new: [1200, 1350, 1100, 1400, 1300, 1250, 1400],
|
||||
returning: [800, 900, 850, 950, 900, 880, 950]
|
||||
}
|
||||
},
|
||||
'1M': {
|
||||
total: 16892,
|
||||
trend: '+2.8%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 10135,
|
||||
returningVisitors: 6757,
|
||||
data: {
|
||||
new: [1300, 1450, 1200, 1500, 1400, 1350, 1500],
|
||||
returning: [850, 950, 900, 1000, 950, 930, 1000]
|
||||
}
|
||||
},
|
||||
'3M': {
|
||||
total: 18234,
|
||||
trend: '+4.9%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 10940,
|
||||
returningVisitors: 7294,
|
||||
data: {
|
||||
new: [1400, 1550, 1300, 1600, 1500, 1450, 1600],
|
||||
returning: [900, 1000, 950, 1050, 1000, 980, 1050]
|
||||
}
|
||||
},
|
||||
'6M': {
|
||||
total: 19876,
|
||||
trend: '+7.2%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 11926,
|
||||
returningVisitors: 7950,
|
||||
data: {
|
||||
new: [1500, 1650, 1400, 1700, 1600, 1550, 1700],
|
||||
returning: [950, 1050, 1000, 1100, 1050, 1030, 1100]
|
||||
}
|
||||
},
|
||||
'1Y': {
|
||||
total: 22145,
|
||||
trend: '+11.3%',
|
||||
direction: 'up' as 'up',
|
||||
newVisitors: 13287,
|
||||
returningVisitors: 8858,
|
||||
data: {
|
||||
new: [1650, 1800, 1550, 1850, 1750, 1700, 1850],
|
||||
returning: [1050, 1150, 1100, 1200, 1150, 1130, 1200]
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = weeklyData[selectedPeriod as keyof typeof weeklyData];
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const maxValue = Math.max(
|
||||
...currentData.data.new,
|
||||
...currentData.data.returning
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-sm border-0 ring-1 ring-inset ring-border/20">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground font-medium">Weekly Visitors</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-foreground">{currentData.total.toLocaleString()}</div>
|
||||
<Badge
|
||||
variant={currentData.direction === 'up' ? 'default' : 'destructive'}
|
||||
className={`h-5 gap-1.5 px-2 text-xs font-medium ${
|
||||
currentData.direction === 'up'
|
||||
? 'bg-green-100 text-green-800 border-green-200'
|
||||
: 'bg-red-100 text-red-800 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{currentData.direction === 'up' ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{currentData.trend}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-2.5 px-2 text-xs hover:bg-muted/50 transition-colors">
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex w-full gap-1.5 rounded-lg bg-muted/50 py-1.5 ring-1 ring-inset ring-border/20">
|
||||
<div className="flex flex-1 items-center justify-center gap-1">
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<div className="size-3 shrink-0 rounded-full border-2 border-background shadow-sm bg-warning-base" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">New visitors</span>
|
||||
</div>
|
||||
<div className="relative w-0 before:absolute before:left-0 before:top-0 before:h-full before:w-px before:bg-border" />
|
||||
<div className="flex flex-1 items-center justify-center gap-1">
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<div className="size-3 shrink-0 rounded-full border-2 border-background shadow-sm bg-success-base" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Returning visitors</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative h-40">
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between">
|
||||
{[0, 25, 50, 75, 100].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="w-full border-t border-border/20"
|
||||
style={{ opacity: line === 0 ? 0 : 0.3 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart lines */}
|
||||
<div className="relative h-full w-full">
|
||||
{/* New visitors line */}
|
||||
<svg className="absolute inset-0 w-full h-full">
|
||||
<polyline
|
||||
points={currentData.data.new.map((value, index) => {
|
||||
const x = (index / (currentData.data.new.length - 1)) * 100;
|
||||
const y = 100 - (value / maxValue) * 100;
|
||||
return `${x}%,${y}%`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke="hsl(var(--warning))"
|
||||
strokeWidth="2"
|
||||
className="drop-shadow-sm"
|
||||
/>
|
||||
{currentData.data.new.map((value, index) => (
|
||||
<circle
|
||||
key={`new-${index}`}
|
||||
cx={`${(index / (currentData.data.new.length - 1)) * 100}%`}
|
||||
cy={`${100 - (value / maxValue) * 100}%`}
|
||||
r="3"
|
||||
fill="hsl(var(--warning))"
|
||||
className="hover:r-4 transition-all"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Returning visitors line */}
|
||||
<svg className="absolute inset-0 w-full h-full">
|
||||
<polyline
|
||||
points={currentData.data.returning.map((value, index) => {
|
||||
const x = (index / (currentData.data.returning.length - 1)) * 100;
|
||||
const y = 100 - (value / maxValue) * 100;
|
||||
return `${x}%,${y}%`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke="hsl(var(--success))"
|
||||
strokeWidth="2"
|
||||
className="drop-shadow-sm"
|
||||
/>
|
||||
{currentData.data.returning.map((value, index) => (
|
||||
<circle
|
||||
key={`returning-${index}`}
|
||||
cx={`${(index / (currentData.data.returning.length - 1)) * 100}%`}
|
||||
cy={`${100 - (value / maxValue) * 100}%`}
|
||||
r="3"
|
||||
fill="hsl(var(--success))"
|
||||
className="hover:r-4 transition-all"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day labels */}
|
||||
<div className="grid auto-cols-fr grid-flow-col gap-0.5 px-4 py-3 text-center">
|
||||
{days.map((day) => (
|
||||
<div key={day} className="text-xs text-muted-foreground">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time Period Selector */}
|
||||
<div className="pt-3 border-t border-border/20">
|
||||
<div className="flex gap-0.5" role="radiogroup">
|
||||
{['1D', '1W', '1M', '3M', '6M', '1Y'].map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
variant={selectedPeriod === period ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className={`h-6 px-3 text-xs first:rounded-l-md last:rounded-r-md transition-colors ${
|
||||
selectedPeriod === period
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted/50 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{period}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Clock, RefreshCw, Download, Trash2, Plus, HardDrive, Calendar, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
function BackupManager({ databaseId, databaseName: _databaseName }) {
|
||||
const [selectedBackup, setSelectedBackup] = useState(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: backups, isLoading } = useQuery({
|
||||
queryKey: ['backups', databaseId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/api/v1/databases/${databaseId}/backups`);
|
||||
return response.backups;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const createBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post(`/api/v1/databases/${databaseId}/backup`, {});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
},
|
||||
});
|
||||
const restoreBackup = useMutation({
|
||||
mutationFn: async (backupId) => {
|
||||
const response = await api.post(`/api/v1/databases/${databaseId}/restore`, {
|
||||
backup_id: backupId,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
setSelectedBackup(null);
|
||||
},
|
||||
});
|
||||
const deleteBackup = useMutation({
|
||||
mutationFn: async (backupId) => {
|
||||
const response = await api.delete(`/api/v1/backups/${backupId}`);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
},
|
||||
});
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024)
|
||||
return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024)
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return _jsx(CheckCircle, { className: "w-4 h-4 text-green-500" });
|
||||
case 'failed':
|
||||
return _jsx(AlertTriangle, { className: "w-4 h-4 text-red-500" });
|
||||
case 'in_progress':
|
||||
return _jsx(Loader2, { className: "w-4 h-4 text-blue-500 animate-spin" });
|
||||
default:
|
||||
return _jsx(Clock, { className: "w-4 h-4 text-gray-500" });
|
||||
}
|
||||
};
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-500';
|
||||
case 'failed':
|
||||
return 'bg-red-500';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
|
||||
}
|
||||
const totalSize = backups?.reduce((sum, b) => sum + b.size_bytes, 0) || 0;
|
||||
const completedBackups = backups?.filter((b) => b.status === 'completed').length || 0;
|
||||
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-semibold", children: "Backups" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [completedBackups, " backups \u2022 ", formatSize(totalSize), " total"] })] }), _jsxs(Button, { onClick: () => createBackup.mutate(), disabled: createBackup.isPending, children: [createBackup.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-2 animate-spin" })) : (_jsx(Plus, { className: "w-4 h-4 mr-2" })), "Create Backup"] })] }), !backups || backups.length === 0 ? (_jsx(Card, { children: _jsxs(CardContent, { className: "p-6 text-center text-muted-foreground", children: [_jsx(HardDrive, { className: "w-12 h-12 mx-auto mb-2 opacity-50" }), _jsx("p", { children: "No backups yet" }), _jsx("p", { className: "text-sm", children: "Create your first backup to protect your data" })] }) })) : (_jsx("div", { className: "space-y-2", children: backups.map((backup) => (_jsx(Card, { className: selectedBackup === backup.id ? 'border-primary' : '', children: _jsxs(CardContent, { className: "p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [getStatusIcon(backup.status), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: backup.name }), _jsxs("div", { className: "flex items-center gap-3 text-sm text-muted-foreground", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(Calendar, { className: "w-3 h-3" }), formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })] }), _jsx("span", { children: formatSize(backup.size_bytes) })] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: `${getStatusColor(backup.status)} text-white`, children: backup.status }), backup.status === 'completed' && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => restoreBackup.mutate(backup.id), disabled: restoreBackup.isPending, children: restoreBackup.isPending ? (_jsx(Loader2, { className: "w-4 h-4 animate-spin" })) : (_jsx(RefreshCw, { className: "w-4 h-4" })) }), _jsx(Button, { variant: "ghost", size: "sm", children: _jsx(Download, { className: "w-4 h-4" }) })] })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => deleteBackup.mutate(backup.id), disabled: deleteBackup.isPending, className: "text-destructive hover:text-destructive", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })] }), backup.completed_at && (_jsxs("div", { className: "mt-2 text-xs text-muted-foreground", children: ["Completed: ", format(new Date(backup.completed_at), 'PPpp'), backup.expires_at && (_jsxs("span", { className: "ml-2", children: ["\u2022 Expires: ", format(new Date(backup.expires_at), 'PP')] }))] }))] }) }, backup.id))) })), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-sm", children: "Backup Schedule" }) }), _jsxs(CardContent, { children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "w-4 h-4 text-muted-foreground" }), _jsx("span", { className: "text-sm", children: "Daily backups at 2:00 AM UTC" })] }), _jsx(Button, { variant: "outline", size: "sm", children: "Configure" })] }), _jsx("p", { className: "text-xs text-muted-foreground mt-2", children: "Backups are retained for 30 days by default" })] })] })] }));
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
Plus,
|
||||
HardDrive,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
|
||||
interface Backup {
|
||||
id: string;
|
||||
database_id: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
interface BackupManagerProps {
|
||||
databaseId: string;
|
||||
databaseName: string;
|
||||
}
|
||||
|
||||
function _BackupManager({ databaseId, databaseName: _databaseName }: BackupManagerProps) {
|
||||
const [selectedBackup, setSelectedBackup] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: backups, isLoading } = useQuery({
|
||||
queryKey: ['backups', databaseId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<{ backups: Backup[] }>(`/api/v1/databases/${databaseId}/backups`);
|
||||
return response.backups;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const createBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post<{ backup: Backup }>(`/api/v1/databases/${databaseId}/backup`, {});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
},
|
||||
});
|
||||
|
||||
const restoreBackup = useMutation({
|
||||
mutationFn: async (backupId: string) => {
|
||||
const response = await api.post<{ message: string }>(`/api/v1/databases/${databaseId}/restore`, {
|
||||
backup_id: backupId,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
setSelectedBackup(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBackup = useMutation({
|
||||
mutationFn: async (backupId: string) => {
|
||||
const response = await api.delete<{ message: string }>(`/api/v1/backups/${backupId}`);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups', databaseId] });
|
||||
},
|
||||
});
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
case 'in_progress':
|
||||
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-500';
|
||||
case 'failed':
|
||||
return 'bg-red-500';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalSize = backups?.reduce((sum, b) => sum + b.size_bytes, 0) || 0;
|
||||
const completedBackups = backups?.filter((b) => b.status === 'completed').length || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Backups</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{completedBackups} backups • {formatSize(totalSize)} total
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => createBackup.mutate()} disabled={createBackup.isPending}>
|
||||
{createBackup.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Create Backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!backups || backups.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
<HardDrive className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No backups yet</p>
|
||||
<p className="text-sm">Create your first backup to protect your data</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<Card key={backup.id} className={selectedBackup === backup.id ? 'border-primary' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(backup.status)}
|
||||
<div>
|
||||
<div className="font-medium">{backup.name}</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
|
||||
</span>
|
||||
<span>{formatSize(backup.size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={`${getStatusColor(backup.status)} text-white`}>
|
||||
{backup.status}
|
||||
</Badge>
|
||||
{backup.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => restoreBackup.mutate(backup.id)}
|
||||
disabled={restoreBackup.isPending}
|
||||
>
|
||||
{restoreBackup.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteBackup.mutate(backup.id)}
|
||||
disabled={deleteBackup.isPending}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{backup.completed_at && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Completed: {format(new Date(backup.completed_at), 'PPpp')}
|
||||
{backup.expires_at && (
|
||||
<span className="ml-2">
|
||||
• Expires: {format(new Date(backup.expires_at), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Backup Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">Daily backups at 2:00 AM UTC</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Backups are retained for 30 days by default
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
interface DatabaseDetailPanelProps {
|
||||
databaseId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function DatabaseDetailPanel({ databaseId, onClose: _onClose }: DatabaseDetailPanelProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -7,14 +7,12 @@ import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Database,
|
||||
Play,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
import {
|
||||
Database,
|
||||
Play,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Activity,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
@@ -24,7 +22,6 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
BarChart3,
|
||||
Users,
|
||||
@@ -134,7 +131,7 @@ const mockDatabaseDetail: DatabaseDetail = {
|
||||
}
|
||||
};
|
||||
|
||||
export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDetailPanelProps) {
|
||||
export default function DatabaseDetailPanel({ databaseId, onClose: _onClose }: DatabaseDetailPanelProps) {
|
||||
const [showConnectionUrl, setShowConnectionUrl] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState<string | null>(null);
|
||||
@@ -148,7 +145,7 @@ export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDet
|
||||
});
|
||||
|
||||
const toggleDatabaseMutation = useMutation({
|
||||
mutationFn: ({ action }: { action: 'start' | 'stop' | 'restart' }) => {
|
||||
mutationFn: ({ action: _action }: { action: 'start' | 'stop' | 'restart' }) => {
|
||||
return new Promise(resolve => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -166,7 +163,7 @@ export default function DatabaseDetailPanel({ databaseId, onClose }: DatabaseDet
|
||||
});
|
||||
|
||||
const restoreBackupMutation = useMutation({
|
||||
mutationFn: (backupId: string) => {
|
||||
mutationFn: (_backupId: string) => {
|
||||
return new Promise(resolve => setTimeout(resolve, 5000));
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Play, RotateCcw, Clock, CheckCircle, XCircle, Loader2, ChevronDown, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { deploymentsApi } from '@/lib/api';
|
||||
const statusConfig = {
|
||||
pending: { color: 'bg-gray-500', icon: Clock, label: 'Pending' },
|
||||
building: { color: 'bg-blue-500', icon: Loader2, label: 'Building', animate: true },
|
||||
deploying: { color: 'bg-yellow-500', icon: Loader2, label: 'Deploying', animate: true },
|
||||
deployed: { color: 'bg-green-500', icon: CheckCircle, label: 'Deployed' },
|
||||
failed: { color: 'bg-red-500', icon: XCircle, label: 'Failed' },
|
||||
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
|
||||
};
|
||||
function DeploymentsPanel({ serviceId, serviceName: _serviceName }) {
|
||||
const [expandedDeployment, setExpandedDeployment] = useState(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: deployments, isLoading } = useQuery({
|
||||
queryKey: ['deployments', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await deploymentsApi.getDeployments(serviceId);
|
||||
return response.deployments;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const createDeployment = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
const response = await deploymentsApi.createDeployment(serviceId, {
|
||||
trigger: 'manual',
|
||||
...data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
},
|
||||
});
|
||||
const rollbackDeployment = useMutation({
|
||||
mutationFn: async (deploymentId) => {
|
||||
const response = await deploymentsApi.rollbackDeployment(deploymentId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
},
|
||||
});
|
||||
if (isLoading) {
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
|
||||
}
|
||||
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h3", { className: "text-lg font-semibold", children: "Deployments" }), _jsxs(Button, { onClick: () => createDeployment.mutate({}), disabled: createDeployment.isPending, size: "sm", children: [createDeployment.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-2 animate-spin" })) : (_jsx(Play, { className: "w-4 h-4 mr-2" })), "Deploy"] })] }), !deployments || deployments.length === 0 ? (_jsx(Card, { children: _jsx(CardContent, { className: "p-6 text-center text-muted-foreground", children: "No deployments yet. Click \"Deploy\" to create your first deployment." }) })) : (_jsx("div", { className: "space-y-2", children: deployments.map((deployment) => {
|
||||
const config = statusConfig[deployment.status] || statusConfig.pending;
|
||||
const StatusIcon = config.icon;
|
||||
const isExpanded = expandedDeployment === deployment.id;
|
||||
return (_jsx(Collapsible, { open: isExpanded, onOpenChange: () => setExpandedDeployment(isExpanded ? null : deployment.id), children: _jsxs(Card, { className: isExpanded ? 'border-primary' : '', children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsx(CardHeader, { className: "cursor-pointer hover:bg-muted/50 transition-colors py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: `p-2 rounded-full ${config.color}`, children: _jsx(StatusIcon, { className: `w-4 h-4 text-white ${config.animate ? 'animate-spin' : ''}` }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-medium", children: deployment.commit_hash
|
||||
? deployment.commit_hash.slice(0, 7)
|
||||
: 'Manual Deploy' }), _jsx(Badge, { variant: "outline", className: "text-xs", children: config.label })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Clock, { className: "w-3 h-3" }), formatDistanceToNow(new Date(deployment.created_at), {
|
||||
addSuffix: true,
|
||||
})] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [deployment.status === 'deployed' && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
rollbackDeployment.mutate(deployment.id);
|
||||
}, disabled: rollbackDeployment.isPending, children: [_jsx(RotateCcw, { className: "w-4 h-4 mr-1" }), "Rollback"] })), _jsx(ChevronDown, { className: `w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}` })] })] }) }) }), _jsx(CollapsibleContent, { children: _jsx(CardContent, { className: "pt-0 pb-4", children: _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "grid grid-cols-2 gap-4 text-sm", children: [_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Image:" }), _jsxs("span", { className: "ml-2 font-mono", children: [deployment.image_name, ":", deployment.image_tag] })] }), deployment.commit_hash && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Commit:" }), _jsx("span", { className: "ml-2 font-mono", children: deployment.commit_hash })] })), deployment.started_at && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Started:" }), _jsx("span", { className: "ml-2", children: new Date(deployment.started_at).toLocaleString() })] })), deployment.completed_at && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Completed:" }), _jsx("span", { className: "ml-2", children: new Date(deployment.completed_at).toLocaleString() })] }))] }), deployment.error && (_jsxs("div", { className: "p-3 bg-destructive/10 rounded-md", children: [_jsx("p", { className: "text-sm text-destructive font-medium", children: "Error:" }), _jsx("p", { className: "text-sm text-destructive/80 mt-1", children: deployment.error })] })), _jsx(DeploymentLogs, { deploymentId: deployment.id })] }) }) })] }) }, deployment.id));
|
||||
}) }))] }));
|
||||
}
|
||||
function DeploymentLogs({ deploymentId }) {
|
||||
const [activeTab, setActiveTab] = useState('build');
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['deployment-logs', deploymentId],
|
||||
queryFn: async () => {
|
||||
const response = await deploymentsApi.getDeployment(deploymentId);
|
||||
return response.deployment;
|
||||
},
|
||||
});
|
||||
if (isLoading) {
|
||||
return (_jsx("div", { className: "flex items-center justify-center p-4", children: _jsx(Loader2, { className: "w-4 h-4 animate-spin" }) }));
|
||||
}
|
||||
const currentLogs = activeTab === 'build' ? logs?.build_log : logs?.runtime_log;
|
||||
return (_jsxs("div", { className: "border rounded-md", children: [_jsxs("div", { className: "flex border-b", children: [_jsxs("button", { onClick: () => setActiveTab('build'), className: `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'build'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'}`, children: [_jsx(Terminal, { className: "w-4 h-4" }), "Build Logs"] }), _jsxs("button", { onClick: () => setActiveTab('runtime'), className: `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${activeTab === 'runtime'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'}`, children: [_jsx(Terminal, { className: "w-4 h-4" }), "Runtime Logs"] })] }), _jsx("div", { className: "p-4 bg-muted/30 max-h-64 overflow-auto", children: _jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap", children: currentLogs || 'No logs available' }) })] }));
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Play,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { deploymentsApi } from '@/lib/api';
|
||||
|
||||
interface Deployment {
|
||||
id: string;
|
||||
service_id: string;
|
||||
commit_hash: string | null;
|
||||
status: 'pending' | 'building' | 'deploying' | 'deployed' | 'failed' | 'rolling_back';
|
||||
image_name: string;
|
||||
image_tag: string;
|
||||
build_log: string;
|
||||
runtime_log: string;
|
||||
error: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DeploymentsPanelProps {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
color: string;
|
||||
icon: typeof Clock;
|
||||
label: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, StatusConfig> = {
|
||||
pending: { color: 'bg-gray-500', icon: Clock, label: 'Pending' },
|
||||
building: { color: 'bg-blue-500', icon: Loader2, label: 'Building', animate: true },
|
||||
deploying: { color: 'bg-yellow-500', icon: Loader2, label: 'Deploying', animate: true },
|
||||
deployed: { color: 'bg-green-500', icon: CheckCircle, label: 'Deployed' },
|
||||
failed: { color: 'bg-red-500', icon: XCircle, label: 'Failed' },
|
||||
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
|
||||
};
|
||||
|
||||
function _DeploymentsPanel({ serviceId, serviceName: _serviceName }: DeploymentsPanelProps) {
|
||||
const [expandedDeployment, setExpandedDeployment] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: deployments, isLoading } = useQuery({
|
||||
queryKey: ['deployments', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await deploymentsApi.getDeployments(serviceId);
|
||||
return response.deployments as Deployment[];
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const createDeployment = useMutation({
|
||||
mutationFn: async (data: { commit_hash?: string; branch?: string }) => {
|
||||
const response = await deploymentsApi.createDeployment(serviceId, {
|
||||
trigger: 'manual',
|
||||
...data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const rollbackDeployment = useMutation({
|
||||
mutationFn: async (deploymentId: string) => {
|
||||
const response = await deploymentsApi.rollbackDeployment(deploymentId);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Deployments</h3>
|
||||
<Button
|
||||
onClick={() => createDeployment.mutate({})}
|
||||
disabled={createDeployment.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{createDeployment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Deploy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!deployments || deployments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
No deployments yet. Click "Deploy" to create your first deployment.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deployments.map((deployment) => {
|
||||
const config = statusConfig[deployment.status] || statusConfig.pending;
|
||||
const StatusIcon = config.icon;
|
||||
const isExpanded = expandedDeployment === deployment.id;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={deployment.id}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => setExpandedDeployment(isExpanded ? null : deployment.id)}
|
||||
>
|
||||
<Card className={isExpanded ? 'border-primary' : ''}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-full ${config.color}`}>
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 text-white ${
|
||||
config.animate ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{deployment.commit_hash
|
||||
? deployment.commit_hash.slice(0, 7)
|
||||
: 'Manual Deploy'}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(deployment.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{deployment.status === 'deployed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
rollbackDeployment.mutate(deployment.id);
|
||||
}}
|
||||
disabled={rollbackDeployment.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Rollback
|
||||
</Button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Image:</span>
|
||||
<span className="ml-2 font-mono">
|
||||
{deployment.image_name}:{deployment.image_tag}
|
||||
</span>
|
||||
</div>
|
||||
{deployment.commit_hash && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Commit:</span>
|
||||
<span className="ml-2 font-mono">
|
||||
{deployment.commit_hash}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deployment.started_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Started:</span>
|
||||
<span className="ml-2">
|
||||
{new Date(deployment.started_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deployment.completed_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Completed:</span>
|
||||
<span className="ml-2">
|
||||
{new Date(deployment.completed_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deployment.error && (
|
||||
<div className="p-3 bg-destructive/10 rounded-md">
|
||||
<p className="text-sm text-destructive font-medium">Error:</p>
|
||||
<p className="text-sm text-destructive/80 mt-1">
|
||||
{deployment.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeploymentLogs deploymentId={deployment.id} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
|
||||
const [activeTab, setActiveTab] = useState<'build' | 'runtime'>('build');
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['deployment-logs', deploymentId],
|
||||
queryFn: async () => {
|
||||
const response = await deploymentsApi.getDeployment(deploymentId);
|
||||
return response.deployment;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentLogs = activeTab === 'build' ? logs?.build_log : logs?.runtime_log;
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('build')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'build'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Build Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('runtime')}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'runtime'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Runtime Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/30 max-h-64 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||
{currentLogs || 'No logs available'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { variablesApi } from '@/lib/api';
|
||||
function EnvVariablesEditor({ serviceId }) {
|
||||
const [variables, setVariables] = useState([]);
|
||||
const [showSecrets, setShowSecrets] = useState({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: existingVars, isLoading } = useQuery({
|
||||
queryKey: ['variables', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await variablesApi.getVariables(serviceId);
|
||||
return response.variables;
|
||||
},
|
||||
});
|
||||
useState(() => {
|
||||
if (existingVars) {
|
||||
setVariables(existingVars.map((v) => ({
|
||||
key: v.key,
|
||||
value: v.is_secret ? '' : v.value,
|
||||
is_secret: v.is_secret,
|
||||
})));
|
||||
}
|
||||
});
|
||||
const updateVariables = useMutation({
|
||||
mutationFn: async (vars) => {
|
||||
const response = await variablesApi.updateVariables(serviceId, vars);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['variables', serviceId] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
});
|
||||
const addVariable = () => {
|
||||
setVariables([...variables, { key: '', value: '', is_secret: false }]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const removeVariable = (index) => {
|
||||
setVariables(variables.filter((_, i) => i !== index));
|
||||
setHasChanges(true);
|
||||
};
|
||||
const updateVariable = (index, field, newValue) => {
|
||||
const updated = [...variables];
|
||||
updated[index] = { ...updated[index], [field]: newValue };
|
||||
setVariables(updated);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const handleSave = () => {
|
||||
const validVars = variables.filter((v) => v.key.trim() !== '');
|
||||
updateVariables.mutate(validVars);
|
||||
};
|
||||
if (isLoading) {
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
|
||||
}
|
||||
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx(CardTitle, { className: "text-lg", children: "Environment Variables" }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", onClick: addVariable, children: [_jsx(Plus, { className: "w-4 h-4 mr-1" }), "Add Variable"] }), hasChanges && (_jsxs(Button, { size: "sm", onClick: handleSave, disabled: updateVariables.isPending, children: [updateVariables.isPending ? (_jsx(Loader2, { className: "w-4 h-4 mr-1 animate-spin" })) : (_jsx(Save, { className: "w-4 h-4 mr-1" })), "Save Changes"] }))] })] }) }), _jsxs(CardContent, { children: [_jsx("div", { className: "space-y-3", children: variables.length === 0 ? (_jsx("div", { className: "text-center py-8 text-muted-foreground", children: "No environment variables configured. Click \"Add Variable\" to add one." })) : (variables.map((variable, index) => (_jsxs("div", { className: "flex items-center gap-2 group", children: [_jsxs("div", { className: "flex-1 grid grid-cols-2 gap-2", children: [_jsx(Input, { placeholder: "KEY", value: variable.key, onChange: (e) => updateVariable(index, 'key', e.target.value), className: "font-mono" }), _jsxs("div", { className: "relative", children: [_jsx(Input, { type: variable.is_secret && !showSecrets[index] ? 'password' : 'text', placeholder: "value", value: variable.value, onChange: (e) => updateVariable(index, 'value', e.target.value), className: "font-mono pr-16" }), variable.is_secret && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0", onClick: () => setShowSecrets({
|
||||
...showSecrets,
|
||||
[index]: !showSecrets[index],
|
||||
}), children: showSecrets[index] ? (_jsx(EyeOff, { className: "w-4 h-4" })) : (_jsx(Eye, { className: "w-4 h-4" })) }))] })] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => updateVariable(index, 'is_secret', !variable.is_secret), className: variable.is_secret ? 'text-amber-500' : 'text-muted-foreground', title: variable.is_secret ? 'Secret (hidden)' : 'Regular variable', children: _jsx(Key, { className: "w-4 h-4" }) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => removeVariable(index), className: "text-destructive opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(Trash2, { className: "w-4 h-4" }) })] }, index)))) }), variables.length > 0 && (_jsxs("div", { className: "mt-4 p-3 bg-muted/30 rounded-md text-sm text-muted-foreground", children: [_jsx("p", { className: "font-medium mb-1", children: "Tips:" }), _jsxs("ul", { className: "list-disc list-inside space-y-1 text-xs", children: [_jsx("li", { children: "Secret variables are encrypted and hidden in the UI" }), _jsx("li", { children: "Changes are applied after the next deployment" }), _jsx("li", { children: "Use uppercase keys with underscores (e.g., DATABASE_URL)" })] })] }))] })] }));
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { variablesApi } from '@/lib/api';
|
||||
|
||||
interface EnvironmentVariable {
|
||||
id: string;
|
||||
service_id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
is_secret: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface EnvVariablesEditorProps {
|
||||
serviceId: string;
|
||||
}
|
||||
|
||||
function _EnvVariablesEditor({ serviceId }: EnvVariablesEditorProps) {
|
||||
const [variables, setVariables] = useState<{ key: string; value: string; is_secret: boolean }[]>([]);
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: existingVars, isLoading } = useQuery({
|
||||
queryKey: ['variables', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await variablesApi.getVariables(serviceId);
|
||||
return response.variables as EnvironmentVariable[];
|
||||
},
|
||||
});
|
||||
|
||||
useState(() => {
|
||||
if (existingVars) {
|
||||
setVariables(
|
||||
existingVars.map((v) => ({
|
||||
key: v.key,
|
||||
value: v.is_secret ? '' : v.value,
|
||||
is_secret: v.is_secret,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const updateVariables = useMutation({
|
||||
mutationFn: async (vars: { key: string; value: string; is_secret: boolean }[]) => {
|
||||
const response = await variablesApi.updateVariables(serviceId, vars);
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['variables', serviceId] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
});
|
||||
|
||||
const addVariable = () => {
|
||||
setVariables([...variables, { key: '', value: '', is_secret: false }]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const removeVariable = (index: number) => {
|
||||
setVariables(variables.filter((_, i) => i !== index));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const updateVariable = (
|
||||
index: number,
|
||||
field: 'key' | 'value' | 'is_secret',
|
||||
newValue: string | boolean
|
||||
) => {
|
||||
const updated = [...variables];
|
||||
updated[index] = { ...updated[index], [field]: newValue };
|
||||
setVariables(updated);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const validVars = variables.filter((v) => v.key.trim() !== '');
|
||||
updateVariables.mutate(validVars);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Environment Variables</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={addVariable}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Variable
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Button size="sm" onClick={handleSave} disabled={updateVariables.isPending}>
|
||||
{updateVariables.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{variables.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No environment variables configured. Click "Add Variable" to add one.
|
||||
</div>
|
||||
) : (
|
||||
variables.map((variable, index) => (
|
||||
<div key={index} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={variable.key}
|
||||
onChange={(e) => updateVariable(index, 'key', e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
variable.is_secret && !showSecrets[index] ? 'password' : 'text'
|
||||
}
|
||||
placeholder="value"
|
||||
value={variable.value}
|
||||
onChange={(e) => updateVariable(index, 'value', e.target.value)}
|
||||
className="font-mono pr-16"
|
||||
/>
|
||||
{variable.is_secret && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() =>
|
||||
setShowSecrets({
|
||||
...showSecrets,
|
||||
[index]: !showSecrets[index],
|
||||
})
|
||||
}
|
||||
>
|
||||
{showSecrets[index] ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateVariable(index, 'is_secret', !variable.is_secret)}
|
||||
className={variable.is_secret ? 'text-amber-500' : 'text-muted-foreground'}
|
||||
title={variable.is_secret ? 'Secret (hidden)' : 'Regular variable'}
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeVariable(index)}
|
||||
className="text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variables.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-md text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">Tips:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Secret variables are encrypted and hidden in the UI</li>
|
||||
<li>Changes are applied after the next deployment</li>
|
||||
<li>Use uppercase keys with underscores (e.g., DATABASE_URL)</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Pause, Download, Trash2, Loader2, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { logsApi } from '@/lib/api';
|
||||
function ServiceLogs({ serviceId, serviceName }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logContainerRef = useRef(null);
|
||||
const eventSourceRef = useRef(null);
|
||||
const { data: initialLogs, isLoading } = useQuery({
|
||||
queryKey: ['logs', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await logsApi.getServiceLogs(serviceId, { lines: 100 });
|
||||
return response.logs.map((log) => ({
|
||||
timestamp: log.timestamp,
|
||||
message: log.message,
|
||||
stream: log.stream,
|
||||
}));
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (initialLogs) {
|
||||
setLogs(initialLogs);
|
||||
}
|
||||
}, [initialLogs]);
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const startStreaming = () => {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
const _token = localStorage.getItem('auth_token');
|
||||
const url = new URL(`${API_BASE_URL}/api/v1/services/${serviceId}/logs`);
|
||||
url.searchParams.append('follow', 'true');
|
||||
const eventSource = new EventSource(url.toString(), {
|
||||
withCredentials: true,
|
||||
});
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data);
|
||||
setLogs((prev) => [...prev.slice(-500), log]);
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse log:', e);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = () => {
|
||||
console.error('EventSource error');
|
||||
eventSource.close();
|
||||
setIsStreaming(false);
|
||||
};
|
||||
eventSourceRef.current = eventSource;
|
||||
setIsStreaming(true);
|
||||
};
|
||||
const stopStreaming = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
const downloadLogs = () => {
|
||||
const content = logs
|
||||
.map((log) => `[${log.timestamp}] ${log.message}`)
|
||||
.join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName}-${new Date().toISOString()}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("div", { className: "flex items-center justify-center", children: _jsx(Loader2, { className: "w-6 h-6 animate-spin text-muted-foreground" }) }) }) }));
|
||||
}
|
||||
return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(CardTitle, { className: "text-lg flex items-center gap-2", children: [_jsx(Terminal, { className: "w-5 h-5" }), "Service Logs"] }), _jsxs("div", { className: "flex items-center gap-2", children: [isStreaming ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: stopStreaming, children: [_jsx(Pause, { className: "w-4 h-4 mr-1" }), "Stop"] })) : (_jsxs(Button, { variant: "outline", size: "sm", onClick: startStreaming, children: [_jsx(Play, { className: "w-4 h-4 mr-1" }), "Stream"] })), _jsxs(Button, { variant: "outline", size: "sm", onClick: downloadLogs, children: [_jsx(Download, { className: "w-4 h-4 mr-1" }), "Download"] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: clearLogs, children: [_jsx(Trash2, { className: "w-4 h-4 mr-1" }), "Clear"] })] })] }) }), _jsxs(CardContent, { children: [_jsx("div", { ref: logContainerRef, onScroll: handleScroll, className: "bg-gray-950 text-gray-100 rounded-md p-4 h-96 overflow-auto font-mono text-sm", children: logs.length === 0 ? (_jsx("div", { className: "text-gray-500 text-center py-8", children: "No logs available. Start the service or enable streaming to see logs." })) : (logs.map((log, index) => (_jsxs("div", { className: `py-0.5 ${log.stream === 'stderr'
|
||||
? 'text-red-400'
|
||||
: log.stream === 'system'
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300'}`, children: [_jsxs("span", { className: "text-gray-600 mr-2", children: ["[", new Date(log.timestamp).toLocaleTimeString(), "]"] }), log.message] }, index)))) }), _jsxs("div", { className: "mt-2 flex items-center justify-between text-xs text-muted-foreground", children: [_jsxs("span", { children: [logs.length, " log entries", autoScroll && ' • Auto-scroll enabled'] }), isStreaming && (_jsxs("span", { className: "flex items-center gap-1 text-green-500", children: [_jsx("span", { className: "w-2 h-2 bg-green-500 rounded-full animate-pulse" }), "Streaming..."] }))] })] })] }));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Pause, Download, Trash2, Loader2, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { logsApi } from '@/lib/api';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
stream: 'stdout' | 'stderr' | 'system';
|
||||
}
|
||||
|
||||
interface ServiceLogsProps {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const { data: initialLogs, isLoading } = useQuery({
|
||||
queryKey: ['logs', serviceId],
|
||||
queryFn: async () => {
|
||||
const response = await logsApi.getServiceLogs(serviceId, { lines: 100 });
|
||||
return response.logs.map((log) => ({
|
||||
timestamp: log.timestamp,
|
||||
message: log.message,
|
||||
stream: log.stream as 'stdout' | 'stderr' | 'system',
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLogs) {
|
||||
setLogs(initialLogs);
|
||||
}
|
||||
}, [initialLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startStreaming = () => {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
const _token = localStorage.getItem('auth_token');
|
||||
|
||||
const url = new URL(`${API_BASE_URL}/api/v1/services/${serviceId}/logs`);
|
||||
url.searchParams.append('follow', 'true');
|
||||
|
||||
const eventSource = new EventSource(url.toString(), {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log: LogEntry = JSON.parse(event.data);
|
||||
setLogs((prev) => [...prev.slice(-500), log]);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('EventSource error');
|
||||
eventSource.close();
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
eventSourceRef.current = eventSource;
|
||||
setIsStreaming(true);
|
||||
};
|
||||
|
||||
const stopStreaming = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const content = logs
|
||||
.map((log) => `[${log.timestamp}] ${log.message}`)
|
||||
.join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName}-${new Date().toISOString()}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isAtBottom);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
Service Logs
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isStreaming ? (
|
||||
<Button variant="outline" size="sm" onClick={stopStreaming}>
|
||||
<Pause className="w-4 h-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={startStreaming}>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Stream
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={downloadLogs}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearLogs}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="bg-gray-950 text-gray-100 rounded-md p-4 h-96 overflow-auto font-mono text-sm"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
No logs available. Start the service or enable streaming to see logs.
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`py-0.5 ${
|
||||
log.stream === 'stderr'
|
||||
? 'text-red-400'
|
||||
: log.stream === 'system'
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-600 mr-2">
|
||||
[{new Date(log.timestamp).toLocaleTimeString()}]
|
||||
</span>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{logs.length} log entries
|
||||
{autoScroll && ' • Auto-scroll enabled'}
|
||||
</span>
|
||||
{isStreaming && (
|
||||
<span className="flex items-center gap-1 text-green-500">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Streaming...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Position } from '@xyflow/react';
|
||||
interface AnimatedEdgeProps {
|
||||
id: string;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
targetPosition: Position;
|
||||
sourcePosition: Position;
|
||||
style?: React.CSSProperties;
|
||||
markerEnd?: string;
|
||||
}
|
||||
export default function AnimatedEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, markerEnd, }: AnimatedEdgeProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position, } from '@xyflow/react';
|
||||
export default function AnimatedEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, markerEnd, }) {
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: edgePath, markerEnd: markerEnd, style: {
|
||||
...style,
|
||||
strokeWidth: 2,
|
||||
stroke: '#94a3b8',
|
||||
} }), _jsx("defs", { children: _jsxs("linearGradient", { id: `gradient-${id}`, x1: "0%", y1: "0%", x2: "100%", y2: "0%", children: [_jsx("stop", { offset: "0%", stopColor: "#3b82f6", stopOpacity: "0" }), _jsx("stop", { offset: "50%", stopColor: "#3b82f6", stopOpacity: "1" }), _jsx("stop", { offset: "100%", stopColor: "#3b82f6", stopOpacity: "0" })] }) }), _jsx("path", { d: edgePath, fill: "none", stroke: `url(#gradient-${id})`, strokeWidth: 2, strokeLinecap: "round", className: "animate-network-flow-egress", style: {
|
||||
strokeDasharray: '10 5',
|
||||
animation: 'networkFlowEgress 2s linear infinite',
|
||||
} }), _jsx(EdgeLabelRenderer, { children: _jsx("div", { style: {
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}, className: "nodrag nopan", children: _jsx("div", { className: "bg-white dark:bg-slate-800 px-2 py-1 rounded text-xs text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-sm", children: "network" }) }) })] }));
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
interface DeploymentTriggersProps {
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
projectId: string;
|
||||
}
|
||||
export default function DeploymentTriggers({ repositoryId, repositoryName, projectId }: DeploymentTriggersProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { gitApi } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
interface WebhookManagerProps {
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
projectId?: string;
|
||||
}
|
||||
export default function WebhookManager({ repositoryId, repositoryName, projectId }: WebhookManagerProps): import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Cpu, HardDrive, Network, Activity, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
function ResourceWidget({ title, icon: Icon, metric, color, sparklineData }) {
|
||||
const trendIcon = metric.trend === 'up' ? TrendingUp : metric.trend === 'down' ? TrendingDown : Minus;
|
||||
const TrendIcon = trendIcon;
|
||||
const trendColor = metric.trend === 'up' ? 'text-green-500' : metric.trend === 'down' ? 'text-red-500' : 'text-gray-500';
|
||||
const change = metric.previous > 0
|
||||
? ((metric.current - metric.previous) / metric.previous * 100).toFixed(1)
|
||||
: '0';
|
||||
return (_jsx(Card, { children: _jsxs(CardContent, { className: "p-4", children: [_jsxs("div", { className: "flex items-start justify-between", children: [_jsx("div", { className: `p-2 rounded-lg ${color}`, children: _jsx(Icon, { className: "w-5 h-5 text-white" }) }), _jsxs("div", { className: `flex items-center gap-1 ${trendColor}`, children: [_jsx(TrendIcon, { className: "w-4 h-4" }), _jsxs("span", { className: "text-sm font-medium", children: [Math.abs(parseFloat(change)), "%"] })] })] }), _jsxs("div", { className: "mt-3", children: [_jsxs("div", { className: "text-2xl font-bold", children: [metric.current.toFixed(1), metric.unit] }), _jsx("div", { className: "text-sm text-muted-foreground", children: title })] }), sparklineData && sparklineData.length > 0 && (_jsx("div", { className: "mt-3 h-8 flex items-end gap-0.5", children: sparklineData.map((value, index) => {
|
||||
const height = (value / Math.max(...sparklineData)) * 100;
|
||||
return (_jsx("div", { className: "flex-1 bg-primary/20 rounded-t", style: { height: `${height}%` } }, index));
|
||||
}) }))] }) }));
|
||||
}
|
||||
function ResourceMonitor({ serviceId }) {
|
||||
const [metrics, setMetrics] = useState({
|
||||
cpu: { current: 0, previous: 0, trend: 'stable', unit: '%' },
|
||||
memory: { current: 0, previous: 0, trend: 'stable', unit: '%' },
|
||||
network: { current: 0, previous: 0, trend: 'stable', unit: ' MB/s' },
|
||||
disk: { current: 0, previous: 0, trend: 'stable', unit: ' GB' },
|
||||
});
|
||||
const [sparklines, setSparklines] = useState({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
network: [],
|
||||
disk: [],
|
||||
});
|
||||
useEffect(() => {
|
||||
const fetchData = () => {
|
||||
const cpuValue = 20 + Math.random() * 60;
|
||||
const memoryValue = 30 + Math.random() * 50;
|
||||
const networkValue = Math.random() * 100;
|
||||
const diskValue = 5 + Math.random() * 20;
|
||||
setMetrics((prev) => ({
|
||||
cpu: { current: cpuValue, previous: prev.cpu.current, trend: cpuValue > prev.cpu.current ? 'up' : cpuValue < prev.cpu.current ? 'down' : 'stable', unit: '%' },
|
||||
memory: { current: memoryValue, previous: prev.memory.current, trend: memoryValue > prev.memory.current ? 'up' : memoryValue < prev.memory.current ? 'down' : 'stable', unit: '%' },
|
||||
network: { current: networkValue, previous: prev.network.current, trend: networkValue > prev.network.current ? 'up' : networkValue < prev.network.current ? 'down' : 'stable', unit: ' MB/s' },
|
||||
disk: { current: diskValue, previous: prev.disk.current, trend: diskValue > prev.disk.current ? 'up' : diskValue < prev.disk.current ? 'down' : 'stable', unit: ' GB' },
|
||||
}));
|
||||
setSparklines((prev) => ({
|
||||
cpu: [...prev.cpu.slice(-20), cpuValue],
|
||||
memory: [...prev.memory.slice(-20), memoryValue],
|
||||
network: [...prev.network.slice(-20), networkValue],
|
||||
disk: [...prev.disk.slice(-20), diskValue],
|
||||
}));
|
||||
};
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serviceId]);
|
||||
return (_jsxs("div", { className: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", children: [_jsx(ResourceWidget, { title: "CPU Usage", icon: Cpu, metric: metrics.cpu, color: "bg-blue-500", sparklineData: sparklines.cpu }), _jsx(ResourceWidget, { title: "Memory", icon: HardDrive, metric: metrics.memory, color: "bg-purple-500", sparklineData: sparklines.memory }), _jsx(ResourceWidget, { title: "Network I/O", icon: Network, metric: metrics.network, color: "bg-green-500", sparklineData: sparklines.network }), _jsx(ResourceWidget, { title: "Disk Usage", icon: Activity, metric: metrics.disk, color: "bg-orange-500", sparklineData: sparklines.disk })] }));
|
||||
}
|
||||
function ServiceHealthIndicator({ status, lastCheck, uptime }) {
|
||||
const statusColors = {
|
||||
healthy: 'bg-green-500',
|
||||
degraded: 'bg-yellow-500',
|
||||
unhealthy: 'bg-red-500',
|
||||
};
|
||||
const statusLabels = {
|
||||
healthy: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
unhealthy: 'Unhealthy',
|
||||
};
|
||||
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: `w-3 h-3 rounded-full ${statusColors[status]} animate-pulse` }), _jsxs("div", { children: [_jsx("div", { className: "font-medium", children: statusLabels[status] }), _jsxs("div", { className: "text-sm text-muted-foreground", children: ["Last check: ", lastCheck] })] })] }), _jsxs("div", { className: "text-right", children: [_jsxs("div", { className: "text-2xl font-bold", children: [uptime.toFixed(2), "%"] }), _jsx("div", { className: "text-sm text-muted-foreground", children: "Uptime" })] })] }) }) }));
|
||||
}
|
||||
function QuickStats({ stats }) {
|
||||
return (_jsxs("div", { className: "grid gap-4 md:grid-cols-4", children: [_jsx(Card, { children: _jsxs(CardContent, { className: "p-4", children: [_jsx("div", { className: "text-sm text-muted-foreground", children: "Total Services" }), _jsx("div", { className: "text-2xl font-bold", children: stats.totalServices })] }) }), _jsx(Card, { children: _jsxs(CardContent, { className: "p-4", children: [_jsx("div", { className: "text-sm text-muted-foreground", children: "Running" }), _jsx("div", { className: "text-2xl font-bold text-green-500", children: stats.runningServices })] }) }), _jsx(Card, { children: _jsxs(CardContent, { className: "p-4", children: [_jsx("div", { className: "text-sm text-muted-foreground", children: "Deployments (24h)" }), _jsx("div", { className: "text-2xl font-bold", children: stats.totalDeployments })] }) }), _jsx(Card, { children: _jsxs(CardContent, { className: "p-4", children: [_jsx("div", { className: "text-sm text-muted-foreground", children: "Active Alerts" }), _jsx("div", { className: "text-2xl font-bold text-red-500", children: stats.activeAlerts })] }) })] }));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user