Merge remote-tracking branch 'upstream/dev' into feature-i18n
46
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
groups:
|
||||||
|
dev-patch-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
dev-major-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "major"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-major-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "major"
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
groups:
|
||||||
|
patch-updates:
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
major-updates:
|
||||||
|
update-types:
|
||||||
|
- "major"
|
12
README.md
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
<h3 align="center">Tunneled Reverse Proxy Server with Access Control</h3>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
_Your own self-hosted zero trust tunnel._
|
_Your own self-hosted zero trust tunnel._
|
||||||
|
@ -83,7 +83,7 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava
|
||||||
|
|
||||||
### Modular Design
|
### Modular Design
|
||||||
|
|
||||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
|
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](https://github.com/PascalMinder/geoblock).
|
||||||
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||||
- Attach as many sites to the central server as you wish.
|
- Attach as many sites to the central server as you wish.
|
||||||
|
|
||||||
|
@ -96,19 +96,19 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava
|
||||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can get a [**VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**](https://my.racknerd.com/aff.php?aff=13788&pid=912). That's a great deal!
|
||||||
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||||
|
|
||||||
2. **Domain Configuration**:
|
1. **Domain Configuration**:
|
||||||
|
|
||||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||||
|
|
||||||
3. **Connect Private Sites**:
|
2. **Connect Private Sites**:
|
||||||
|
|
||||||
- Install Newt or use another WireGuard client on private sites.
|
- Install Newt or use another WireGuard client on private sites.
|
||||||
- Automatically establish a connection from these sites to the central server.
|
- Automatically establish a connection from these sites to the central server.
|
||||||
|
|
||||||
4. **Expose Resources**:
|
3. **Expose Resources**:
|
||||||
|
|
||||||
- Add resources to the central server and configure access control rules.
|
- Add resources to the central server and configure access control rules.
|
||||||
- Access these resources securely from anywhere.
|
- Access these resources securely from anywhere.
|
||||||
|
|
122
install/main.go
|
@ -9,6 +9,7 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -17,6 +18,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
@ -57,9 +59,18 @@ type Config struct {
|
||||||
func main() {
|
func main() {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
// check if the user is root
|
// check if docker is not installed and the user is root
|
||||||
if os.Geteuid() != 0 {
|
if !isDockerInstalled() {
|
||||||
fmt.Println("This script must be run as root")
|
if os.Geteuid() != 0 {
|
||||||
|
fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the user is in the docker group (linux only)
|
||||||
|
if !isUserInDockerGroup() {
|
||||||
|
fmt.Println("You are not in the docker group.")
|
||||||
|
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,6 +94,27 @@ func main() {
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
installDocker()
|
||||||
|
// try to start docker service but ignore errors
|
||||||
|
if err := startDockerService(); err != nil {
|
||||||
|
fmt.Println("Error starting Docker service:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Docker service started successfully!")
|
||||||
|
}
|
||||||
|
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
||||||
|
fmt.Println("Waiting for Docker to start...")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if isDockerRunning() {
|
||||||
|
fmt.Println("Docker is running!")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Println("Docker is not running yet, waiting...")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
if !isDockerRunning() {
|
||||||
|
fmt.Println("Docker is still not running after 10 seconds. Please check the installation.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Docker installed successfully!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -397,7 +429,7 @@ func installDocker() error {
|
||||||
return fmt.Errorf("failed to detect Linux distribution: %v", err)
|
return fmt.Errorf("failed to detect Linux distribution: %v", err)
|
||||||
}
|
}
|
||||||
osRelease := string(output)
|
osRelease := string(output)
|
||||||
|
|
||||||
// Detect system architecture
|
// Detect system architecture
|
||||||
archCmd := exec.Command("uname", "-m")
|
archCmd := exec.Command("uname", "-m")
|
||||||
archOutput, err := archCmd.Output()
|
archOutput, err := archCmd.Output()
|
||||||
|
@ -405,7 +437,7 @@ func installDocker() error {
|
||||||
return fmt.Errorf("failed to detect system architecture: %v", err)
|
return fmt.Errorf("failed to detect system architecture: %v", err)
|
||||||
}
|
}
|
||||||
arch := strings.TrimSpace(string(archOutput))
|
arch := strings.TrimSpace(string(archOutput))
|
||||||
|
|
||||||
// Map architecture to Docker's architecture naming
|
// Map architecture to Docker's architecture naming
|
||||||
var dockerArch string
|
var dockerArch string
|
||||||
switch arch {
|
switch arch {
|
||||||
|
@ -438,11 +470,31 @@ func installDocker() error {
|
||||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
`, dockerArch))
|
`, dockerArch))
|
||||||
case strings.Contains(osRelease, "ID=fedora"):
|
case strings.Contains(osRelease, "ID=fedora"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
// Detect Fedora version to handle DNF 5 changes
|
||||||
|
versionCmd := exec.Command("bash", "-c", "grep VERSION_ID /etc/os-release | cut -d'=' -f2 | tr -d '\"'")
|
||||||
|
versionOutput, err := versionCmd.Output()
|
||||||
|
var fedoraVersion int
|
||||||
|
if err == nil {
|
||||||
|
if v, parseErr := strconv.Atoi(strings.TrimSpace(string(versionOutput))); parseErr == nil {
|
||||||
|
fedoraVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use appropriate DNF syntax based on version
|
||||||
|
var repoCmd string
|
||||||
|
if fedoraVersion >= 41 {
|
||||||
|
// DNF 5 syntax for Fedora 41+
|
||||||
|
repoCmd = "dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo"
|
||||||
|
} else {
|
||||||
|
// DNF 4 syntax for Fedora < 41
|
||||||
|
repoCmd = "dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
dnf -y install dnf-plugins-core &&
|
dnf -y install dnf-plugins-core &&
|
||||||
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo &&
|
%s &&
|
||||||
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
`)
|
`, repoCmd))
|
||||||
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
|
case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"):
|
||||||
installCmd = exec.Command("bash", "-c", `
|
installCmd = exec.Command("bash", "-c", `
|
||||||
zypper install -y docker docker-compose &&
|
zypper install -y docker docker-compose &&
|
||||||
|
@ -466,11 +518,26 @@ func installDocker() error {
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported Linux distribution")
|
return fmt.Errorf("unsupported Linux distribution")
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd.Stdout = os.Stdout
|
installCmd.Stdout = os.Stdout
|
||||||
installCmd.Stderr = os.Stderr
|
installCmd.Stderr = os.Stderr
|
||||||
return installCmd.Run()
|
return installCmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startDockerService() error {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
} else if runtime.GOOS == "darwin" {
|
||||||
|
// On macOS, Docker is usually started via the Docker Desktop application
|
||||||
|
fmt.Println("Please start Docker Desktop manually on macOS.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unsupported operating system for starting Docker service")
|
||||||
|
}
|
||||||
|
|
||||||
func isDockerInstalled() bool {
|
func isDockerInstalled() bool {
|
||||||
cmd := exec.Command("docker", "--version")
|
cmd := exec.Command("docker", "--version")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
@ -479,6 +546,43 @@ func isDockerInstalled() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUserInDockerGroup() bool {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
// Docker group is not applicable on macOS
|
||||||
|
// So we assume that the user can run Docker commands
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return true // Root user can run Docker commands anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the current user is in the docker group
|
||||||
|
if dockerGroup, err := user.LookupGroup("docker"); err == nil {
|
||||||
|
if currentUser, err := user.Current(); err == nil {
|
||||||
|
if currentUserGroupIds, err := currentUser.GroupIds(); err == nil {
|
||||||
|
for _, groupId := range currentUserGroupIds {
|
||||||
|
if groupId == dockerGroup.Gid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eventually, if any of the checks fail, we assume the user cannot run Docker commands
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDockerRunning checks if the Docker daemon is running by using the `docker info` command.
|
||||||
|
func isDockerRunning() bool {
|
||||||
|
cmd := exec.Command("docker", "info")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
|
// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
|
||||||
func executeDockerComposeCommandWithArgs(args ...string) error {
|
func executeDockerComposeCommandWithArgs(args ...string) error {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
@ -619,4 +723,4 @@ func generateRandomSecretKey() string {
|
||||||
b[i] = charset[seededRand.Intn(len(charset))]
|
b[i] = charset[seededRand.Intn(len(charset))]
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
3606
package-lock.json
generated
107
package.json
|
@ -20,94 +20,95 @@
|
||||||
"email": "email dev --dir server/emails/templates --port 3005"
|
"email": "email dev --dir server/emails/templates --port 3005"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^7.3.0",
|
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "3.9.1",
|
"@hookform/resolvers": "3.9.1",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.2",
|
"@radix-ui/react-avatar": "1.1.10",
|
||||||
"@radix-ui/react-checkbox": "1.1.3",
|
"@radix-ui/react-checkbox": "1.3.2",
|
||||||
"@radix-ui/react-collapsible": "1.1.2",
|
"@radix-ui/react-collapsible": "1.1.11",
|
||||||
"@radix-ui/react-dialog": "1.1.4",
|
"@radix-ui/react-dialog": "1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
"@radix-ui/react-icons": "1.3.2",
|
||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.7",
|
||||||
"@radix-ui/react-popover": "1.1.4",
|
"@radix-ui/react-popover": "1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.4",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-radio-group": "1.2.2",
|
"@radix-ui/react-radio-group": "1.3.7",
|
||||||
"@radix-ui/react-select": "2.1.4",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-separator": "1.1.1",
|
"@radix-ui/react-select": "2.2.5",
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
"@radix-ui/react-separator": "1.1.7",
|
||||||
"@radix-ui/react-switch": "1.1.2",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
"@radix-ui/react-tabs": "1.1.2",
|
"@radix-ui/react-switch": "1.2.5",
|
||||||
"@radix-ui/react-toast": "1.2.4",
|
"@radix-ui/react-tabs": "1.1.12",
|
||||||
"@react-email/components": "0.0.36",
|
"@radix-ui/react-toast": "1.2.14",
|
||||||
"@react-email/render": "^1.0.6",
|
"@react-email/components": "0.0.41",
|
||||||
"@react-email/tailwind": "1.0.4",
|
"@react-email/render": "^1.1.2",
|
||||||
|
"@react-email/tailwind": "1.0.5",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "^3.6.0",
|
"arctic": "^3.7.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.9.0",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.3.3",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-rate-limit": "7.5.0",
|
"express-rate-limit": "7.5.0",
|
||||||
"glob": "11.0.0",
|
"glob": "11.0.2",
|
||||||
"helmet": "8.0.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.2",
|
||||||
"jmespath": "^0.16.0",
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.511.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.2.4",
|
"next": "15.3.3",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"next-themes": "0.4.4",
|
"next-themes": "0.4.6",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.16",
|
"nodemailer": "6.9.16",
|
||||||
"npm": "^11.2.0",
|
"npm": "^11.4.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.0.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.1.0",
|
||||||
"react-easy-sort": "^1.6.0",
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.54.2",
|
"react-hook-form": "7.56.4",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.6.3",
|
"semver": "7.7.2",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.3.3",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.18.0",
|
"ws": "8.18.2",
|
||||||
"zod": "3.24.1",
|
"zod": "3.25.46",
|
||||||
"zod-validation-error": "3.4.0"
|
"zod-validation-error": "3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.32.0",
|
"@dotenvx/dotenvx": "1.44.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.3",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.18",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
|
@ -115,22 +116,22 @@
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/react": "19.1.1",
|
"@types/react": "19.1.6",
|
||||||
"@types/react-dom": "19.1.2",
|
"@types/react-dom": "19.1.5",
|
||||||
"@types/semver": "7.5.8",
|
"@types/semver": "7.7.0",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "8.5.13",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"drizzle-kit": "0.30.6",
|
"drizzle-kit": "0.30.6",
|
||||||
"esbuild": "0.25.2",
|
"esbuild": "0.25.5",
|
||||||
"esbuild-node-externals": "1.18.0",
|
"esbuild-node-externals": "1.18.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react-email": "4.0.6",
|
"react-email": "4.0.15",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"yargs": "17.7.2"
|
"yargs": "18.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"emblor": {
|
"emblor": {
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
|
width="900.82861"
|
||||||
|
height="955.20648"
|
||||||
|
viewBox="0 0 238.34422 252.7317"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
x="0px"
|
id="svg420"
|
||||||
y="0px"
|
inkscape:export-filename="logo.svg"
|
||||||
viewBox="0 0 399.99999 400.00002"
|
inkscape:export-xdpi="221.14999"
|
||||||
enable-background="new 0 0 419.528 419.528"
|
inkscape:export-ydpi="221.14999"
|
||||||
xml:space="preserve"
|
|
||||||
id="svg52"
|
|
||||||
sodipodi:docname="noun-pangolin-1798092.svg"
|
|
||||||
width="400"
|
|
||||||
height="400"
|
|
||||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
id="defs56" /><sodipodi:namedview
|
<sodipodi:namedview
|
||||||
id="namedview54"
|
id="namedview422"
|
||||||
pagecolor="#ffffff"
|
pagecolor="#ffffff"
|
||||||
bordercolor="#666666"
|
bordercolor="#666666"
|
||||||
borderopacity="1.0"
|
borderopacity="1.0"
|
||||||
|
@ -24,15 +23,18 @@
|
||||||
inkscape:pageopacity="0.0"
|
inkscape:pageopacity="0.0"
|
||||||
inkscape:pagecheckerboard="0"
|
inkscape:pagecheckerboard="0"
|
||||||
inkscape:deskcolor="#d1d1d1"
|
inkscape:deskcolor="#d1d1d1"
|
||||||
showgrid="false"
|
inkscape:document-units="mm"
|
||||||
inkscape:zoom="1.9583914"
|
showgrid="false" />
|
||||||
inkscape:cx="209.86611"
|
<defs
|
||||||
inkscape:cy="262.20499"
|
id="defs417" />
|
||||||
inkscape:window-width="3840"
|
<g
|
||||||
inkscape:window-height="2136"
|
inkscape:label="Layer 1"
|
||||||
inkscape:window-x="0"
|
inkscape:groupmode="layer"
|
||||||
inkscape:window-y="0"
|
id="layer1"
|
||||||
inkscape:window-maximized="1"
|
transform="translate(-13.119542,-5.9258171)">
|
||||||
inkscape:current-layer="svg52" /><path
|
<path
|
||||||
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
|
d="m 213.66176,90.072122 c 4.95655,0 8.97383,4.018046 8.97383,8.973827 0,4.956581 -4.01728,8.974621 -8.97383,8.974621 -4.95657,0 -8.97462,-4.01804 -8.97462,-8.974621 0,-4.955781 4.01805,-8.973827 8.97462,-8.973827 z m 35.2316,37.450998 c -0.90048,29.80928 -23.66033,69.21262 -54.51292,79.34466 -36.04206,11.836 -63.40991,-5.92226 -72.08409,-26.74061 -6.75754,-16.21966 -1.65117,-35.62363 10.96266,-43.83669 10.6506,-6.93533 30.48543,-8.76736 47.15454,2.19144 -5.85627,-15.34246 -21.62491,-25.4256 -35.59101,-28.49424 -13.96613,-3.06867 -28.38324,0.43858 -38.74504,5.69946 13.29071,-14.68572 44.40801,-28.946049 78.24077,-10.95958 22.67676,12.05491 32.43775,28.93208 42.0489,51.72763 C 251.59637,117.87858 234.026,71.411066 203.39074,43.794029 172.15544,15.636686 129.95516,4.340214 97.668803,6.103155 108.32483,12.678273 120.84625,22.06586 132.41209,33.053363 81.298533,26.697169 39.174705,38.314245 13.119542,73.749217 27.67508,70.878527 46.868833,69.073666 65.974711,70.016861 28.737658,96.252107 7.1124298,140.38147 18.105298,186.43137 c 6.718497,-11.74129 16.767711,-25.84558 28.726275,-38.62863 -3.677175,34.36994 1.42836,80.83745 45.62293,110.85478 -2.25587,-9.42394 -4.08014,-20.88443 -4.91466,-33.0154 20.673197,16.1282 50.685067,29.42205 87.917917,20.24096 65.77679,-16.21975 83.34719,-79.78335 73.4356,-118.35996"
|
||||||
id="path46" /></svg>
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0776283"
|
||||||
|
id="path32" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
@ -1,39 +1,22 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
|
width="900.82861"
|
||||||
|
height="955.20648"
|
||||||
|
viewBox="0 0 238.34422 252.7317"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
x="0px"
|
id="svg420"
|
||||||
y="0px"
|
|
||||||
viewBox="0 0 399.99999 400.00002"
|
|
||||||
enable-background="new 0 0 419.528 419.528"
|
|
||||||
xml:space="preserve"
|
|
||||||
id="svg52"
|
|
||||||
sodipodi:docname="pangolin_orange.svg"
|
|
||||||
width="400"
|
|
||||||
height="400"
|
|
||||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
id="defs56" /><sodipodi:namedview
|
<defs
|
||||||
id="namedview54"
|
id="defs417" />
|
||||||
pagecolor="#ffffff"
|
<g
|
||||||
bordercolor="#666666"
|
id="layer1"
|
||||||
borderopacity="1.0"
|
transform="translate(-13.119542,-5.9258171)">
|
||||||
inkscape:showpageshadow="2"
|
<path
|
||||||
inkscape:pageopacity="0.0"
|
d="m 213.66176,90.072122 c 4.95655,0 8.97383,4.018046 8.97383,8.973827 0,4.956581 -4.01728,8.974621 -8.97383,8.974621 -4.95657,0 -8.97462,-4.01804 -8.97462,-8.974621 0,-4.955781 4.01805,-8.973827 8.97462,-8.973827 z m 35.2316,37.450998 c -0.90048,29.80928 -23.66033,69.21262 -54.51292,79.34466 -36.04206,11.836 -63.40991,-5.92226 -72.08409,-26.74061 -6.75754,-16.21966 -1.65117,-35.62363 10.96266,-43.83669 10.6506,-6.93533 30.48543,-8.76736 47.15454,2.19144 -5.85627,-15.34246 -21.62491,-25.4256 -35.59101,-28.49424 -13.96613,-3.06867 -28.38324,0.43858 -38.74504,5.69946 13.29071,-14.68572 44.40801,-28.946049 78.24077,-10.95958 22.67676,12.05491 32.43775,28.93208 42.0489,51.72763 C 251.59637,117.87858 234.026,71.411066 203.39074,43.794029 172.15544,15.636686 129.95516,4.340214 97.668803,6.103155 108.32483,12.678273 120.84625,22.06586 132.41209,33.053363 81.298533,26.697169 39.174705,38.314245 13.119542,73.749217 27.67508,70.878527 46.868833,69.073666 65.974711,70.016861 28.737658,96.252107 7.1124298,140.38147 18.105298,186.43137 c 6.718497,-11.74129 16.767711,-25.84558 28.726275,-38.62863 -3.677175,34.36994 1.42836,80.83745 45.62293,110.85478 -2.25587,-9.42394 -4.08014,-20.88443 -4.91466,-33.0154 20.673197,16.1282 50.685067,29.42205 87.917917,20.24096 65.77679,-16.21975 83.34719,-79.78335 73.4356,-118.35996"
|
||||||
inkscape:pagecheckerboard="0"
|
style="fill:#f36118;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0776283"
|
||||||
inkscape:deskcolor="#d1d1d1"
|
id="path32" />
|
||||||
showgrid="false"
|
</g>
|
||||||
inkscape:zoom="1.9583914"
|
</svg>
|
||||||
inkscape:cx="127.40048"
|
|
||||||
inkscape:cy="262.71561"
|
|
||||||
inkscape:window-width="1436"
|
|
||||||
inkscape:window-height="1236"
|
|
||||||
inkscape:window-x="2208"
|
|
||||||
inkscape:window-y="511"
|
|
||||||
inkscape:window-maximized="0"
|
|
||||||
inkscape:current-layer="svg52" /><path
|
|
||||||
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
|
|
||||||
id="path46"
|
|
||||||
style="fill:#f97315;fill-opacity:1" /></svg>
|
|
||||||
|
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 7.4 KiB |
BIN
public/logo/pangolin_profile_picture.png
Normal file
After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 36 KiB |
BIN
public/logo/word_mark_black.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
public/logo/word_mark_white.png
Normal file
After Width: | Height: | Size: 33 KiB |
|
@ -41,7 +41,10 @@ export const sites = sqliteTable("sites", {
|
||||||
megabytesOut: integer("bytesOut"),
|
megabytesOut: integer("bytesOut"),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
type: text("type").notNull(), // "newt" or "wireguard"
|
type: text("type").notNull(), // "newt" or "wireguard"
|
||||||
online: integer("online", { mode: "boolean" }).notNull().default(false)
|
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||||
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
|
|
|
@ -29,7 +29,7 @@ import {
|
||||||
getUserOrgs,
|
getUserOrgs,
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
verifyIsLoggedInUser,
|
verifyIsLoggedInUser,
|
||||||
verifyApiKeyAccess,
|
verifyApiKeyAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
@ -124,6 +124,37 @@ authenticated.delete(
|
||||||
site.deleteSite
|
site.deleteSite
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId/docker/status",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
site.dockerStatus
|
||||||
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId/docker/online",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
site.dockerOnline
|
||||||
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/site/:siteId/docker/check",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
site.checkDockerSocket
|
||||||
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/site/:siteId/docker/trigger",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
site.triggerFetchContainers
|
||||||
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId/docker/containers",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
site.listContainers
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site/:siteId/resource",
|
"/org/:orgId/site/:siteId/resource",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { handleRegisterMessage } from "./newt";
|
import {
|
||||||
|
handleRegisterMessage,
|
||||||
|
handleDockerStatusMessage,
|
||||||
|
handleDockerContainersMessage
|
||||||
|
} from "./newt";
|
||||||
import { MessageHandler } from "./ws";
|
import { MessageHandler } from "./ws";
|
||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
"newt/wg/register": handleRegisterMessage,
|
"newt/wg/register": handleRegisterMessage,
|
||||||
};
|
"newt/socket/status": handleDockerStatusMessage,
|
||||||
|
"newt/socket/containers": handleDockerContainersMessage
|
||||||
|
};
|
||||||
|
|
22
server/routers/newt/dockerSocket.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import { sendToClient } from "../ws";
|
||||||
|
|
||||||
|
export const dockerSocketCache = new NodeCache({
|
||||||
|
stdTTL: 3600 // seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
export function fetchContainers(newtId: string) {
|
||||||
|
const payload = {
|
||||||
|
type: `newt/socket/fetch`,
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
sendToClient(newtId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dockerSocket(newtId: string) {
|
||||||
|
const payload = {
|
||||||
|
type: `newt/socket/check`,
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
sendToClient(newtId, payload);
|
||||||
|
}
|
57
server/routers/newt/handleSocketMessages.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { MessageHandler } from "../ws";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { dockerSocketCache } from "./dockerSocket";
|
||||||
|
|
||||||
|
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||||
|
const { message, newt } = context;
|
||||||
|
|
||||||
|
logger.info("Handling Docker socket check response");
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Newt not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
|
||||||
|
const { available, socketPath } = message.data;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
logger.info(`Newt ${newt.newtId} has Docker socket access`);
|
||||||
|
dockerSocketCache.set(`${newt.newtId}:socketPath`, socketPath, 0);
|
||||||
|
dockerSocketCache.set(`${newt.newtId}:isAvailable`, available, 0);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleDockerContainersMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const { message, newt } = context;
|
||||||
|
|
||||||
|
logger.info("Handling Docker containers response");
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Newt not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
|
||||||
|
const { containers } = message.data;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containers && containers.length > 0) {
|
||||||
|
dockerSocketCache.set(`${newt.newtId}:dockerContainers`, containers, 0);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./createNewt";
|
export * from "./createNewt";
|
||||||
export * from "./getToken";
|
export * from "./getToken";
|
||||||
export * from "./handleRegisterMessage";
|
export * from "./handleRegisterMessage";
|
||||||
|
export * from "./handleSocketMessages";
|
|
@ -3,5 +3,6 @@ export * from "./createSite";
|
||||||
export * from "./deleteSite";
|
export * from "./deleteSite";
|
||||||
export * from "./updateSite";
|
export * from "./updateSite";
|
||||||
export * from "./listSites";
|
export * from "./listSites";
|
||||||
export * from "./listSiteRoles"
|
export * from "./listSiteRoles";
|
||||||
export * from "./pickSiteDefaults";
|
export * from "./pickSiteDefaults";
|
||||||
|
export * from "./socketIntegration";
|
||||||
|
|
283
server/routers/site/socketIntegration.ts
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { newts, sites } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import stoi from "@server/lib/stoi";
|
||||||
|
import { sendToClient } from "../ws";
|
||||||
|
import {
|
||||||
|
fetchContainers,
|
||||||
|
dockerSocketCache,
|
||||||
|
dockerSocket
|
||||||
|
} from "../newt/dockerSocket";
|
||||||
|
|
||||||
|
export interface ContainerNetwork {
|
||||||
|
networkId: string;
|
||||||
|
endpointId: string;
|
||||||
|
gateway?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
ipPrefixLen?: number;
|
||||||
|
macAddress?: string;
|
||||||
|
aliases?: string[];
|
||||||
|
dnsNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerPort {
|
||||||
|
privatePort: number;
|
||||||
|
publicPort?: number;
|
||||||
|
type: "tcp" | "udp";
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Container {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: "running" | "exited" | "paused" | "created";
|
||||||
|
status: string;
|
||||||
|
ports?: ContainerPort[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
created: number;
|
||||||
|
networks: Record<string, ContainerNetwork>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteIdParamsSchema = z
|
||||||
|
.object({
|
||||||
|
siteId: z.string().transform(stoi).pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const DockerStatusSchema = z
|
||||||
|
.object({
|
||||||
|
isAvailable: z.boolean(),
|
||||||
|
socketPath: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
function validateSiteIdParams(params: any) {
|
||||||
|
const parsedParams = siteIdParamsSchema.safeParse(params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsedParams.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteAndValidateNewt(siteId: number) {
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
throw createHttpError(HttpCode.NOT_FOUND, "Site not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.type !== "newt") {
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"This endpoint is only for Newt sites"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNewtBySiteId(siteId: number) {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
throw createHttpError(HttpCode.NOT_FOUND, "Newt not found for site");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteAndNewt(siteId: number) {
|
||||||
|
const site = await getSiteAndValidateNewt(siteId);
|
||||||
|
const newt = await getNewtBySiteId(siteId);
|
||||||
|
return { site, newt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function asyncHandler(
|
||||||
|
operation: (siteId: number) => Promise<any>,
|
||||||
|
successMessage: string
|
||||||
|
) {
|
||||||
|
return async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const { siteId } = validateSiteIdParams(req.params);
|
||||||
|
const result = await operation(siteId);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: result,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: successMessage,
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core business logic functions
|
||||||
|
async function triggerFetch(siteId: number) {
|
||||||
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Triggering fetch containers for site ${siteId} with Newt ${newt.newtId}`
|
||||||
|
);
|
||||||
|
fetchContainers(newt.newtId);
|
||||||
|
|
||||||
|
// clear the cache for this Newt ID so that the site has to keep asking for the containers
|
||||||
|
// this is to ensure that the site always gets the latest data
|
||||||
|
dockerSocketCache.del(`${newt.newtId}:dockerContainers`);
|
||||||
|
|
||||||
|
return { siteId, newtId: newt.newtId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryContainers(siteId: number) {
|
||||||
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
|
const result = dockerSocketCache.get(
|
||||||
|
`${newt.newtId}:dockerContainers`
|
||||||
|
) as Container[];
|
||||||
|
if (!result) {
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.TOO_EARLY,
|
||||||
|
"Nothing found yet. Perhaps the fetch is still in progress? Wait a bit and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDockerAvailable(siteId: number): Promise<boolean> {
|
||||||
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
|
const key = `${newt.newtId}:isAvailable`;
|
||||||
|
const isAvailable = dockerSocketCache.get(key);
|
||||||
|
|
||||||
|
return !!isAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDockerStatus(
|
||||||
|
siteId: number
|
||||||
|
): Promise<z.infer<typeof DockerStatusSchema>> {
|
||||||
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
|
const keys = ["isAvailable", "socketPath"];
|
||||||
|
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
isAvailable: dockerSocketCache.get(mappedKeys[0]) as boolean,
|
||||||
|
socketPath: dockerSocketCache.get(mappedKeys[1]) as string | undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSocket(
|
||||||
|
siteId: number
|
||||||
|
): Promise<{ siteId: number; newtId: string }> {
|
||||||
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Checking Docker socket for site ${siteId} with Newt ${newt.newtId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger the Docker socket check
|
||||||
|
dockerSocket(newt.newtId);
|
||||||
|
return { siteId, newtId: newt.newtId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type GetDockerStatusResponse = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getDockerStatus>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ListContainersResponse = Awaited<
|
||||||
|
ReturnType<typeof queryContainers>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TriggerFetchResponse = Awaited<ReturnType<typeof triggerFetch>>;
|
||||||
|
|
||||||
|
// Route handlers
|
||||||
|
export const triggerFetchContainers = asyncHandler(
|
||||||
|
triggerFetch,
|
||||||
|
"Fetch containers triggered successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const listContainers = asyncHandler(
|
||||||
|
queryContainers,
|
||||||
|
"Containers retrieved successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dockerOnline = asyncHandler(async (siteId: number) => {
|
||||||
|
const isAvailable = await isDockerAvailable(siteId);
|
||||||
|
return { isAvailable };
|
||||||
|
}, "Docker availability checked successfully");
|
||||||
|
|
||||||
|
export const dockerStatus = asyncHandler(
|
||||||
|
getDockerStatus,
|
||||||
|
"Docker status retrieved successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function checkDockerSocket(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const { siteId } = validateSiteIdParams(req.params);
|
||||||
|
const result = await checkSocket(siteId);
|
||||||
|
|
||||||
|
// Notify the Newt client about the Docker socket check
|
||||||
|
sendToClient(result.newtId, {
|
||||||
|
type: "newt/socket/check",
|
||||||
|
data: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: result,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Docker socket checked successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ const updateSiteParamsSchema = z
|
||||||
const updateSiteBodySchema = z
|
const updateSiteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
dockerSocketEnabled: z.boolean().optional(),
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
|
@ -11,14 +10,18 @@ import {
|
||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import Link from "next/link";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
const { resource, authInfo } = useResourceContext();
|
const { resource, authInfo, site } = useResourceContext();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const { isEnabled, isAvailable } = useDockerSocket(resource.siteId);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||||
|
@ -30,7 +33,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
{t('resourceInfo')}
|
{t('resourceInfo')}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections cols={4}>
|
<InfoSections cols={isEnabled ? 5 : 4}>
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
@ -69,6 +72,24 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
{resource.siteName}
|
{resource.siteName}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
{isEnabled && (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{isAvailable ? (
|
||||||
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>Online</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
|
<span>Offline</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
@ -94,7 +115,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span>{resource.enabled ? t('enabled') : t('disabled')}</span>
|
<span>
|
||||||
|
{resource.enabled ? t('enabled') : t('disabled')}
|
||||||
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
|
|
|
@ -13,15 +13,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import ResourceInfoBox from "./ResourceInfoBox";
|
import ResourceInfoBox from "./ResourceInfoBox";
|
||||||
import {
|
import { GetSiteResponse } from "@server/routers/site";
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator
|
|
||||||
} from "@app/components/ui/breadcrumb";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
interface ResourceLayoutProps {
|
interface ResourceLayoutProps {
|
||||||
|
@ -37,6 +29,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
|
|
||||||
let authInfo = null;
|
let authInfo = null;
|
||||||
let resource = null;
|
let resource = null;
|
||||||
|
let site = null;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||||
`/resource/${params.resourceId}`,
|
`/resource/${params.resourceId}`,
|
||||||
|
@ -51,6 +44,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
redirect(`/${params.orgId}/settings/resources`);
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch site info
|
||||||
|
if (resource.siteId) {
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
|
||||||
|
`/site/${resource.siteId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
site = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
AxiosResponse<GetResourceAuthInfoResponse>
|
AxiosResponse<GetResourceAuthInfoResponse>
|
||||||
|
@ -112,7 +118,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<ResourceProvider resource={resource} authInfo={authInfo}>
|
<ResourceProvider
|
||||||
|
site={site}
|
||||||
|
resource={resource}
|
||||||
|
authInfo={authInfo}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ResourceInfoBox />
|
<ResourceInfoBox />
|
||||||
<HorizontalTabs items={navItems}>
|
<HorizontalTabs items={navItems}>
|
||||||
|
|
|
@ -41,7 +41,6 @@ import {
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
|
@ -73,6 +72,7 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger
|
CollapsibleTrigger
|
||||||
} from "@app/components/ui/collapsible";
|
} from "@app/components/ui/collapsible";
|
||||||
|
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
|
@ -163,6 +163,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedIp = addTargetForm.watch("ip");
|
||||||
|
const watchedPort = addTargetForm.watch("port");
|
||||||
|
|
||||||
const tlsSettingsForm = useForm<TlsSettingsValues>({
|
const tlsSettingsForm = useForm<TlsSettingsValues>({
|
||||||
resolver: zodResolver(tlsSettingsSchema),
|
resolver: zodResolver(tlsSettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
@ -762,12 +765,32 @@ export default function ReverseProxyTargets(props: {
|
||||||
control={addTargetForm.control}
|
control={addTargetForm.control}
|
||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="relative">
|
||||||
<FormLabel>{t('targetAddr')}</FormLabel>
|
<FormLabel>{t('targetAddr')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="ip" {...field} />
|
<Input id="ip" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{site && (
|
||||||
|
<ContainersSelector
|
||||||
|
site={site}
|
||||||
|
onContainerSelect={(
|
||||||
|
hostname,
|
||||||
|
port
|
||||||
|
) => {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"ip",
|
||||||
|
hostname
|
||||||
|
);
|
||||||
|
if (port) {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"port",
|
||||||
|
port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -793,12 +816,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outlinePrimary"
|
variant="outlinePrimary"
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
disabled={
|
disabled={!(watchedIp && watchedPort)}
|
||||||
!(
|
|
||||||
addTargetForm.getValues("ip") &&
|
|
||||||
addTargetForm.getValues("port")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('targetSubmit')}
|
{t('targetSubmit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -271,7 +271,7 @@ PersistentKeepalive = 5`
|
||||||
- NEWT_ID=${siteDefaults?.newtId}
|
- NEWT_ID=${siteDefaults?.newtId}
|
||||||
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
|
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
|
||||||
|
|
||||||
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
const newtConfigDockerRun = `docker run -dit fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||||
|
|
||||||
return loadingPage ? (
|
return loadingPage ? (
|
||||||
<LoaderPlaceholder height="300px" />
|
<LoaderPlaceholder height="300px" />
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
|
@ -44,7 +45,8 @@ export default function GeneralPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().nonempty(t('nameRequired'))
|
name: z.string().nonempty("Name is required"),
|
||||||
|
dockerSocketEnabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
@ -52,7 +54,8 @@ export default function GeneralPage() {
|
||||||
const form = useForm<GeneralFormValues>({
|
const form = useForm<GeneralFormValues>({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: site?.name
|
name: site?.name,
|
||||||
|
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
@ -62,7 +65,8 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
await api
|
await api
|
||||||
.post(`/site/${site?.siteId}`, {
|
.post(`/site/${site?.siteId}`, {
|
||||||
name: data.name
|
name: data.name,
|
||||||
|
dockerSocketEnabled: data.dockerSocketEnabled
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -75,7 +79,10 @@ export default function GeneralPage() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSite({ name: data.name });
|
updateSite({
|
||||||
|
name: data.name,
|
||||||
|
dockerSocketEnabled: data.dockerSocketEnabled
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('siteUpdated'),
|
title: t('siteUpdated'),
|
||||||
|
@ -104,7 +111,7 @@ export default function GeneralPage() {
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-6"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -123,6 +130,31 @@ export default function GeneralPage() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dockerSocketEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="docker-socket-enabled"
|
||||||
|
label="Enable Docker Socket"
|
||||||
|
defaultChecked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Enable Docker Socket discovery
|
||||||
|
for populating container
|
||||||
|
information, useful in resource
|
||||||
|
targets.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
|
@ -257,7 +257,7 @@ PersistentKeepalive = 5`;
|
||||||
- NEWT_SECRET=${secret}`
|
- NEWT_SECRET=${secret}`
|
||||||
],
|
],
|
||||||
"Docker Run": [
|
"Docker Run": [
|
||||||
`docker run -it fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
podman: {
|
podman: {
|
||||||
|
@ -280,7 +280,7 @@ Restart=always
|
||||||
WantedBy=default.target`
|
WantedBy=default.target`
|
||||||
],
|
],
|
||||||
"Podman Run": [
|
"Podman Run": [
|
||||||
`podman run -it docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -48,8 +48,8 @@ export default function DashboardLoginForm({
|
||||||
<Image
|
<Image
|
||||||
src={`/logo/pangolin_orange.svg`}
|
src={`/logo/pangolin_orange.svg`}
|
||||||
alt={t('pangolinLogoAlt')}
|
alt={t('pangolinLogoAlt')}
|
||||||
width="100"
|
width={100}
|
||||||
height="100"
|
height={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
|
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
717
src/components/ContainersSelector.tsx
Normal file
|
@ -0,0 +1,717 @@
|
||||||
|
import { useEffect, useState, FC, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender,
|
||||||
|
getFilteredRowModel,
|
||||||
|
VisibilityState
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuCheckboxItem
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
|
||||||
|
import { GetSiteResponse, Container } from "@server/routers/site";
|
||||||
|
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||||
|
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||||
|
|
||||||
|
// Type definitions based on the JSON structure
|
||||||
|
|
||||||
|
interface ContainerSelectorProps {
|
||||||
|
site: GetSiteResponse;
|
||||||
|
onContainerSelect?: (hostname: string, port?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||||
|
site,
|
||||||
|
onContainerSelect
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
const { isAvailable, containers, fetchContainers } = useDockerSocket(
|
||||||
|
site.siteId
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAvailable) {
|
||||||
|
fetchContainers();
|
||||||
|
}
|
||||||
|
}, [isAvailable]);
|
||||||
|
|
||||||
|
if (!site || !isAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContainerSelect = (container: Container, port?: number) => {
|
||||||
|
// Extract hostname - prefer IP address from networks, fallback to container name
|
||||||
|
const hostname = getContainerHostname(container);
|
||||||
|
onContainerSelect?.(hostname, port);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="squareOutline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-[35%] right-0"
|
||||||
|
>
|
||||||
|
<span className="scale-125">🐋</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Containers in <b>{site.name}</b>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select any container (w/ port) to use as target for
|
||||||
|
your resource
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-hidden min-h-0">
|
||||||
|
<DockerContainersTable
|
||||||
|
containers={containers}
|
||||||
|
onContainerSelect={handleContainerSelect}
|
||||||
|
onRefresh={() => fetchContainers()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="squareOutline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-[35%] right-0"
|
||||||
|
>
|
||||||
|
<span className="scale-125">🐋</span>
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>
|
||||||
|
Containers in <b>{site.name}</b>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Select any container to use as target for your resource
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4">
|
||||||
|
<DockerContainersTable
|
||||||
|
containers={containers}
|
||||||
|
onContainerSelect={handleContainerSelect}
|
||||||
|
onRefresh={fetchContainers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter className="pt-2">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DockerContainersTable: FC<{
|
||||||
|
containers: Container[];
|
||||||
|
onContainerSelect: (container: Container, port?: number) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}> = ({ containers, onContainerSelect, onRefresh }) => {
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [hideContainersWithoutPorts, setHideContainersWithoutPorts] =
|
||||||
|
useState(true);
|
||||||
|
const [hideStoppedContainers, setHideStoppedContainers] = useState(false);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||||
|
labels: false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setGlobalFilter(searchInput);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
const getExposedPorts = useCallback((container: Container): number[] => {
|
||||||
|
const ports: number[] = [];
|
||||||
|
|
||||||
|
container.ports?.forEach((port) => {
|
||||||
|
if (port.privatePort) {
|
||||||
|
ports.push(port.privatePort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...new Set(ports)]; // Remove duplicates
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const globalFilterFunction = useCallback(
|
||||||
|
(row: any, columnId: string, value: string) => {
|
||||||
|
const container = row.original as Container;
|
||||||
|
const searchValue = value.toLowerCase();
|
||||||
|
|
||||||
|
// Search across all relevant fields
|
||||||
|
const searchableFields = [
|
||||||
|
container.name,
|
||||||
|
container.image,
|
||||||
|
container.state,
|
||||||
|
container.status,
|
||||||
|
getContainerHostname(container),
|
||||||
|
...Object.keys(container.networks),
|
||||||
|
...Object.values(container.networks)
|
||||||
|
.map((n) => n.ipAddress)
|
||||||
|
.filter(Boolean),
|
||||||
|
...getExposedPorts(container).map((p) => p.toString()),
|
||||||
|
...Object.entries(container.labels).flat()
|
||||||
|
];
|
||||||
|
|
||||||
|
return searchableFields.some((field) =>
|
||||||
|
field?.toString().toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[getExposedPorts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnDef<Container>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.original.name}</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "image",
|
||||||
|
header: "Image",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{row.original.image}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "state",
|
||||||
|
header: "State",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.original.state === "running"
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.original.state}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "networks",
|
||||||
|
header: "Networks",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const networks = Object.keys(row.original.networks);
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{networks.length > 0
|
||||||
|
? networks.map((n) => (
|
||||||
|
<Badge key={n} variant="outlinePrimary">
|
||||||
|
{n}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "hostname",
|
||||||
|
header: "Hostname/IP",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm font-mono">
|
||||||
|
{getContainerHostname(row.original)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "labels",
|
||||||
|
header: "Labels",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const labels = row.original.labels || {};
|
||||||
|
const labelEntries = Object.entries(labels);
|
||||||
|
|
||||||
|
if (labelEntries.length === 0) {
|
||||||
|
return <span className="text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs hover:bg-muted"
|
||||||
|
>
|
||||||
|
{labelEntries.length} label
|
||||||
|
{labelEntries.length !== 1 ? "s" : ""}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="top" align="start">
|
||||||
|
<ScrollArea className="w-64 h-64">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">
|
||||||
|
Container Labels
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{labelEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="text-xs">
|
||||||
|
<div className="font-mono font-medium text-foreground">
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-muted-foreground pl-2 break-all">
|
||||||
|
{value || "<empty>"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ports",
|
||||||
|
header: "Ports",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ports = getExposedPorts(row.original);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{ports.slice(0, 2).map((port) => (
|
||||||
|
<Button
|
||||||
|
key={port}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
onContainerSelect(row.original, port)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{port}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{ports.length > 2 && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="link" size="sm">
|
||||||
|
+{ports.length - 2} more
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="top"
|
||||||
|
className="w-auto"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
{ports.slice(2).map((port) => (
|
||||||
|
<Button
|
||||||
|
key={port}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
onContainerSelect(
|
||||||
|
row.original,
|
||||||
|
port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{port}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onContainerSelect(row.original)}
|
||||||
|
disabled={row.original.state !== "running"}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialFilters = useMemo(() => {
|
||||||
|
let filtered = containers;
|
||||||
|
|
||||||
|
// Filter by port visibility
|
||||||
|
if (hideContainersWithoutPorts) {
|
||||||
|
filtered = filtered.filter((container) => {
|
||||||
|
const ports = getExposedPorts(container);
|
||||||
|
return ports.length > 0; // Show only containers WITH ports
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by container state
|
||||||
|
if (hideStoppedContainers) {
|
||||||
|
filtered = filtered.filter((container) => {
|
||||||
|
return container.state === "running";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [
|
||||||
|
containers,
|
||||||
|
hideContainersWithoutPorts,
|
||||||
|
hideStoppedContainers,
|
||||||
|
getExposedPorts
|
||||||
|
]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: initialFilters,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
globalFilterFn: globalFilterFunction,
|
||||||
|
state: {
|
||||||
|
globalFilter,
|
||||||
|
columnVisibility
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialFilters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1 flex items-center justify-center py-8">
|
||||||
|
<div className="text-center text-muted-foreground space-y-3">
|
||||||
|
{(hideContainersWithoutPorts ||
|
||||||
|
hideStoppedContainers) &&
|
||||||
|
containers.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
No containers found matching the current
|
||||||
|
filters.
|
||||||
|
</p>
|
||||||
|
<div className="space-x-2">
|
||||||
|
{hideContainersWithoutPorts && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setHideContainersWithoutPorts(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show containers without ports
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hideStoppedContainers && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setHideStoppedContainers(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Show stopped containers
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
No containers found. Make sure Docker containers
|
||||||
|
are running.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
|
||||||
|
<div className="p-3 border-b bg-background space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={`Search across ${initialFilters.length} containers...`}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSearchInput(event.target.value)
|
||||||
|
}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
{searchInput &&
|
||||||
|
table.getFilteredRowModel().rows.length > 0 && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length}{" "}
|
||||||
|
result
|
||||||
|
{table.getFilteredRowModel().rows.length !==
|
||||||
|
1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
{(hideContainersWithoutPorts ||
|
||||||
|
hideStoppedContainers) && (
|
||||||
|
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||||
|
{Number(
|
||||||
|
hideContainersWithoutPorts
|
||||||
|
) + Number(hideStoppedContainers)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Filter Options
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={hideContainersWithoutPorts}
|
||||||
|
onCheckedChange={
|
||||||
|
setHideContainersWithoutPorts
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ports
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={hideStoppedContainers}
|
||||||
|
onCheckedChange={setHideStoppedContainers}
|
||||||
|
>
|
||||||
|
Stopped
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
{(hideContainersWithoutPorts ||
|
||||||
|
hideStoppedContainers) && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="p-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHideContainersWithoutPorts(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
setHideStoppedContainers(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-full text-xs"
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Columns className="h-4 w-4" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Toggle Columns
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(
|
||||||
|
!!value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.id === "hostname"
|
||||||
|
? "Hostname/IP"
|
||||||
|
: column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Refresh containers list"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto relative flex-1">
|
||||||
|
<Table sticky>
|
||||||
|
<TableHeader sticky className="bg-background border-b">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={
|
||||||
|
row.original.state !== "running"
|
||||||
|
? "opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{searchInput && !globalFilter ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
`No containers found matching "${globalFilter}".`
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContainerHostname(container: Container): string {
|
||||||
|
// First, try to get IP from networks
|
||||||
|
const networks = Object.values(container.networks);
|
||||||
|
for (const network of networks) {
|
||||||
|
if (network.ipAddress) {
|
||||||
|
return network.ipAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to container name (works in Docker networks)
|
||||||
|
return container.name;
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { SidebarNav } from "@app/components/SidebarNav";
|
import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
@ -23,6 +23,7 @@ import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
|
@ -61,6 +62,31 @@ export function Layout({
|
||||||
const isAdminPage = pathname?.startsWith("/admin");
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [path, setPath] = useState<string>(""); // Default logo path
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function getPath() {
|
||||||
|
let lightOrDark = theme;
|
||||||
|
|
||||||
|
if (theme === "system" || !theme) {
|
||||||
|
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lightOrDark === "light") {
|
||||||
|
return "/logo/word_mark_black.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/logo/word_mark_white.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
setPath(getPath());
|
||||||
|
}, [theme, env]);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -140,12 +166,14 @@ export function Layout({
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center hidden md:block"
|
className="flex items-center hidden md:block"
|
||||||
>
|
>
|
||||||
<Image
|
{path && (
|
||||||
src="/logo/pangolin_orange.svg"
|
<Image
|
||||||
alt="Pangolin Logo"
|
src={path}
|
||||||
width={35}
|
alt="Pangolin Logo"
|
||||||
height={35}
|
width={110}
|
||||||
/>
|
height={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{showBreadcrumbs && (
|
{showBreadcrumbs && (
|
||||||
<div className="hidden md:block overflow-x-auto scrollbar-hide">
|
<div className="hidden md:block overflow-x-auto scrollbar-hide">
|
||||||
|
|
58
src/components/ui/scroll-area.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
import { cn } from "@app/lib/cn"
|
||||||
|
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
|
@ -1,121 +1,138 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn"
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
export function TableContainer({ children }: { children: React.ReactNode }) {
|
export function TableContainer({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="border rounded-lg bg-card">{children}</div>
|
return <div className="border rounded-lg bg-card">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement> & { sticky?: boolean }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, sticky, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div
|
||||||
<table
|
className={cn("relative w-full", {
|
||||||
ref={ref}
|
"overflow-auto": !sticky
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
})}
|
||||||
{...props}
|
>
|
||||||
/>
|
<table
|
||||||
</div>
|
ref={ref}
|
||||||
))
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
Table.displayName = "Table"
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
const TableHeader = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, sticky, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
))
|
ref={ref}
|
||||||
TableHeader.displayName = "TableHeader"
|
className={cn(
|
||||||
|
"[&_tr]:border-b",
|
||||||
|
{
|
||||||
|
"sticky top-0": sticky
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
const TableBody = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableBody.displayName = "TableBody"
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
const TableFooter = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableFooter.displayName = "TableFooter"
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
const TableRow = React.forwardRef<
|
||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors data-[state=selected]:bg-muted",
|
"border-b transition-colors data-[state=selected]:bg-muted",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableRow.displayName = "TableRow"
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
const TableHead = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableHead.displayName = "TableHead"
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
const TableCell = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("p-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
className={cn(
|
||||||
{...props}
|
"p-3 align-middle [&:has([role=checkbox])]:pr-0",
|
||||||
/>
|
className
|
||||||
))
|
)}
|
||||||
TableCell.displayName = "TableCell"
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
const TableCaption = React.forwardRef<
|
||||||
HTMLTableCaptionElement,
|
HTMLTableCaptionElement,
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCaption.displayName = "TableCaption"
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableFooter,
|
TableFooter,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import { GetSiteResponse } from "@server/routers/site";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
interface ResourceContextType {
|
interface ResourceContextType {
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
|
site: GetSiteResponse | null;
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
|
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
|
||||||
updateAuthInfo: (
|
updateAuthInfo: (
|
||||||
|
|
199
src/hooks/useDockerSocket.ts
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useEnvContext } from "./useEnvContext";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
GetDockerStatusResponse,
|
||||||
|
GetSiteResponse,
|
||||||
|
ListContainersResponse,
|
||||||
|
TriggerFetchResponse
|
||||||
|
} from "@server/routers/site";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { toast } from "./useToast";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export function useDockerSocket(siteId: number) {
|
||||||
|
if (!siteId) {
|
||||||
|
throw new Error("Site ID is required to use Docker Socket");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [site, setSite] = useState<GetSiteResponse>();
|
||||||
|
const [dockerSocket, setDockerSocket] = useState<GetDockerStatusResponse>();
|
||||||
|
const [containers, setContainers] = useState<Container[]>([]);
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const { dockerSocketEnabled: isEnabled = true } = site || {};
|
||||||
|
const { isAvailable = false, socketPath } = dockerSocket || {};
|
||||||
|
|
||||||
|
const fetchSite = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<GetSiteResponse>>(
|
||||||
|
`/site/${siteId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
setSite(res.data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch resource",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while fetching resource"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [api, siteId]);
|
||||||
|
|
||||||
|
const checkDockerSocket = useCallback(async () => {
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.warn("Docker socket is not enabled for this site.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/site/${siteId}/docker/check`);
|
||||||
|
console.log("Docker socket check response:", res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check Docker socket:", error);
|
||||||
|
}
|
||||||
|
}, [api, siteId, isEnabled]);
|
||||||
|
|
||||||
|
const getDockerSocketStatus = useCallback(async () => {
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.warn("Docker socket is not enabled for this site.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<GetDockerStatusResponse>>(
|
||||||
|
`/site/${siteId}/docker/status`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
setDockerSocket(res.data.data);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to get Docker status:", res);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to get Docker status",
|
||||||
|
description:
|
||||||
|
"An error occurred while fetching Docker status."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get Docker status:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to get Docker status",
|
||||||
|
description: "An error occurred while fetching Docker status."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [api, siteId, isEnabled]);
|
||||||
|
|
||||||
|
const getContainers = useCallback(
|
||||||
|
async (maxRetries: number = 3) => {
|
||||||
|
if (!isEnabled || !isAvailable) {
|
||||||
|
console.warn("Docker socket is not enabled or available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchContainerList = async () => {
|
||||||
|
if (!isEnabled || !isAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (attempt < maxRetries) {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListContainersResponse>
|
||||||
|
>(`/site/${siteId}/docker/containers`);
|
||||||
|
setContainers(res.data.data);
|
||||||
|
return;
|
||||||
|
} catch (error: any) {
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
// Check if the error is a 425 (Too Early) status
|
||||||
|
if (error?.response?.status === 425) {
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
// Ask the newt server to check containers
|
||||||
|
await fetchContainerList();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in 250ms...`
|
||||||
|
);
|
||||||
|
await sleep(250);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Max retry attempts reached. Containers may still be loading."
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Containers not ready",
|
||||||
|
description:
|
||||||
|
"Containers are still loading. Please try again in a moment."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch Docker containers:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch containers",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
"An error occurred while fetching containers"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post<AxiosResponse<TriggerFetchResponse>>(
|
||||||
|
`/site/${siteId}/docker/trigger`
|
||||||
|
);
|
||||||
|
// TODO: identify a way to poll the server for latest container list periodically?
|
||||||
|
await fetchContainerList();
|
||||||
|
return res.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to trigger Docker containers:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, siteId, isEnabled, isAvailable]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSite();
|
||||||
|
}, [fetchSite]);
|
||||||
|
|
||||||
|
// 2. Docker socket status monitoring
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled || isAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDockerSocket();
|
||||||
|
getDockerSocketStatus();
|
||||||
|
|
||||||
|
}, [isEnabled, isAvailable, checkDockerSocket, getDockerSocketStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEnabled,
|
||||||
|
isAvailable: isEnabled && isAvailable,
|
||||||
|
socketPath,
|
||||||
|
containers,
|
||||||
|
check: checkDockerSocket,
|
||||||
|
status: getDockerSocketStatus,
|
||||||
|
fetchContainers: getContainers
|
||||||
|
};
|
||||||
|
}
|
|
@ -3,19 +3,22 @@
|
||||||
import ResourceContext from "@app/contexts/resourceContext";
|
import ResourceContext from "@app/contexts/resourceContext";
|
||||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import { GetSiteResponse } from "@server/routers/site";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface ResourceProviderProps {
|
interface ResourceProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
|
site: GetSiteResponse | null;
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceProvider({
|
export function ResourceProvider({
|
||||||
children,
|
children,
|
||||||
|
site,
|
||||||
resource: serverResource,
|
resource: serverResource,
|
||||||
authInfo: serverAuthInfo,
|
authInfo: serverAuthInfo
|
||||||
}: ResourceProviderProps) {
|
}: ResourceProviderProps) {
|
||||||
const [resource, setResource] =
|
const [resource, setResource] =
|
||||||
useState<GetResourceResponse>(serverResource);
|
useState<GetResourceResponse>(serverResource);
|
||||||
|
@ -37,7 +40,7 @@ export function ResourceProvider({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
...updatedResource,
|
...updatedResource
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -56,14 +59,14 @@ export function ResourceProvider({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
...updatedAuthInfo,
|
...updatedAuthInfo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourceContext.Provider
|
<ResourceContext.Provider
|
||||||
value={{ resource, updateResource, authInfo, updateAuthInfo }}
|
value={{ resource, updateResource, site, authInfo, updateAuthInfo }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ResourceContext.Provider>
|
</ResourceContext.Provider>
|
||||||
|
|