mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
v1.0.19
This commit is contained in:
+40
-68
@@ -1,30 +1,21 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# =============================================================================
|
||||
# Dockhand Docker Image - Security-Hardened Build
|
||||
# Dockhand Docker Image - Node.js Runtime (Security-Hardened Build)
|
||||
# =============================================================================
|
||||
# This Dockerfile builds a custom Wolfi OS from scratch using apko, ensuring:
|
||||
# - Full transparency (no dependency on pre-built Chainguard images)
|
||||
# - Reproducible builds from open-source Wolfi packages
|
||||
# - Minimal attack surface with only required packages
|
||||
#
|
||||
# Bun is copied from the official oven/bun image (app-builder stage).
|
||||
# For CPUs without AVX support (Celeron, Atom, pre-Haswell), build with:
|
||||
# docker build --build-arg BUN_VARIANT=baseline -t dockhand:baseline .
|
||||
# Uses Node.js instead of Bun to eliminate BoringSSL native memory leaks
|
||||
# on mTLS connections. Same Wolfi-based security-hardened OS.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: OS Generator (Alpine + apko tool)
|
||||
# -----------------------------------------------------------------------------
|
||||
# We use Alpine because it has a shell. This lets us download and run apko
|
||||
# to build our custom Wolfi OS from scratch using open-source packages.
|
||||
FROM alpine:3.21 AS os-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Install apko tool (latest stable release)
|
||||
# apko is the tool Chainguard uses to build their images - we use it directly
|
||||
# Install apko tool
|
||||
ARG APKO_VERSION=0.30.34
|
||||
RUN apk add --no-cache curl unzip \
|
||||
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
|
||||
@@ -32,9 +23,7 @@ RUN apk add --no-cache curl unzip \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/apko
|
||||
|
||||
# Generate apko.yaml for current target architecture only
|
||||
# We build single-arch to avoid multi-arch layer confusion in extraction
|
||||
# Note: Bun is NOT included here - it's copied from app-builder stage for CPU compatibility
|
||||
# Generate apko.yaml — Node.js instead of Bun
|
||||
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
|
||||
&& printf '%s\n' \
|
||||
"contents:" \
|
||||
@@ -47,6 +36,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - ca-certificates" \
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - nodejs-24" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose" \
|
||||
" - docker-cli-buildx" \
|
||||
@@ -58,6 +48,8 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - curl" \
|
||||
" - tini" \
|
||||
" - su-exec" \
|
||||
" - glibc" \
|
||||
" - libstdc++" \
|
||||
"entrypoint:" \
|
||||
" command: /bin/sh -l" \
|
||||
"archs:" \
|
||||
@@ -65,7 +57,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
> apko.yaml
|
||||
|
||||
# Build the OS tarball and extract rootfs
|
||||
# apko creates an OCI tarball - we need to extract the actual filesystem layer
|
||||
RUN apko build apko.yaml dockhand-base:latest output.tar \
|
||||
&& mkdir -p rootfs \
|
||||
&& tar -xf output.tar \
|
||||
@@ -73,67 +64,46 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
|
||||
&& tar -xzf "$LAYER" -C rootfs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Application Builder
|
||||
# Stage 2: Application Builder (pure Node.js)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Using Debian to avoid Alpine musl thread creation issues
|
||||
# Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build
|
||||
FROM oven/bun:1.3.5-debian AS app-builder
|
||||
|
||||
# Build argument for Bun variant (regular or baseline)
|
||||
# baseline is for CPUs without AVX support (Celeron, Atom, pre-Haswell)
|
||||
ARG BUN_VARIANT=regular
|
||||
ARG TARGETARCH
|
||||
FROM node:24-slim AS app-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
# libnss-wrapper: needed for git SSH with arbitrary UIDs on read-only containers (getpwuid workaround)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates libnss-wrapper && rm -rf /var/lib/apt/lists/* \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
jq git curl python3 make g++ libnss-wrapper \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
|
||||
|
||||
# Copy package files and install ALL dependencies (needed for build)
|
||||
COPY package.json bun.lock* bunfig.toml ./
|
||||
RUN bun install --frozen-lockfile
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Build the application
|
||||
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build
|
||||
# Production dependencies only (rebuilds native addons like better-sqlite3)
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
# Prepare production node_modules (do this in builder where we have compilers)
|
||||
# This ensures native addons compile correctly before copying to hardened runtime
|
||||
RUN rm -rf node_modules && bun install --production --frozen-lockfile \
|
||||
&& rm -rf node_modules/@types node_modules/bun-types
|
||||
|
||||
# Download baseline Bun binary if BUN_VARIANT=baseline (for CPUs without AVX)
|
||||
# Only applies to amd64 - ARM64 doesn't have AVX concept
|
||||
ARG BUN_VERSION=1.3.5
|
||||
RUN if [ "$BUN_VARIANT" = "baseline" ] && [ "$TARGETARCH" = "amd64" ]; then \
|
||||
echo "Downloading Bun baseline binary for CPUs without AVX support..." && \
|
||||
curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" -o /tmp/bun.zip && \
|
||||
unzip -o /tmp/bun.zip -d /tmp && \
|
||||
cp /tmp/bun-linux-x64-baseline/bun /usr/local/bin/bun && \
|
||||
chmod +x /usr/local/bin/bun && \
|
||||
rm -rf /tmp/bun.zip /tmp/bun-linux-x64-baseline && \
|
||||
echo "Bun baseline binary installed successfully"; \
|
||||
fi
|
||||
# Build Go collector
|
||||
FROM golang:1.24 AS go-builder
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM scratch
|
||||
|
||||
# Install our custom-built Wolfi OS (now we have /bin/sh!)
|
||||
# Install custom Wolfi OS with Node.js
|
||||
COPY --from=os-builder /work/rootfs/ /
|
||||
|
||||
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
|
||||
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
|
||||
# For baseline builds (BUN_VARIANT=baseline), this contains the baseline binary (no AVX requirement)
|
||||
# For regular builds, this contains the standard oven/bun binary
|
||||
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Copy libnss_wrapper for git SSH with arbitrary UIDs (same cross-copy pattern as Bun above)
|
||||
# Copy libnss_wrapper for git SSH with arbitrary UIDs
|
||||
COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so
|
||||
|
||||
WORKDIR /app
|
||||
@@ -149,20 +119,22 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
PUID=1001 \
|
||||
PGID=1001
|
||||
|
||||
# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary)
|
||||
# Note: docker-cli-buildx package already creates the buildx symlink
|
||||
# Create docker compose plugin symlink
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group (using busybox commands)
|
||||
# Create dockhand user and group
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
|
||||
|
||||
# Copy application files with correct ownership (avoids layer duplication from chown -R)
|
||||
# Copy application files with correct ownership
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/build/subprocesses/ ./subprocesses/
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/server.js ./
|
||||
|
||||
# Copy Go collector binary
|
||||
COPY --from=go-builder --chown=dockhand:dockhand /app/bin/collection-worker ./bin/collection-worker
|
||||
|
||||
# Copy database migrations
|
||||
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
|
||||
@@ -171,15 +143,15 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy entrypoint script (root-owned, executable)
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts
|
||||
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create data directories with correct ownership
|
||||
# Create data directories
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
@@ -189,4 +161,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["bun", "run", "./build/index.js"]
|
||||
CMD ["node", "/app/server.js"]
|
||||
|
||||
@@ -7,3 +7,7 @@ exact = true
|
||||
[run]
|
||||
# Enable source maps for better error messages
|
||||
sourcemap = "external"
|
||||
|
||||
[test]
|
||||
# Disable auth before any integration test runs
|
||||
preload = ["./tests/helpers/preload.ts"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.24
|
||||
@@ -0,0 +1,940 @@
|
||||
// Collection worker for Dockhand.
|
||||
//
|
||||
// A lightweight Go binary that handles background Docker API calls for
|
||||
// metrics collection, event streaming, and disk usage checks.
|
||||
// Communicates with the Node.js parent process via JSON lines on
|
||||
// stdin (commands) and stdout (results).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Inbound (stdin) messages from Node.js parent.
|
||||
type InMessage struct {
|
||||
Type string `json:"type"`
|
||||
EnvID int `json:"envId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Config *EnvConfig `json:"config,omitempty"`
|
||||
ConnectionType string `json:"connectionType,omitempty"`
|
||||
HawserToken string `json:"hawserToken,omitempty"`
|
||||
IntervalMs int `json:"intervalMs,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
PollIntervalMs int `json:"pollIntervalMs,omitempty"`
|
||||
}
|
||||
|
||||
type EnvConfig struct {
|
||||
Type string `json:"type"` // "socket", "http", "https"
|
||||
SocketPath string `json:"socketPath,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
CA string `json:"ca,omitempty"`
|
||||
Cert string `json:"cert,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
SkipVerify bool `json:"skipVerify,omitempty"`
|
||||
}
|
||||
|
||||
// Outbound (stdout) messages to Node.js parent.
|
||||
type OutMessage struct {
|
||||
Type string `json:"type"`
|
||||
EnvID int `json:"envId,omitempty"`
|
||||
// Status
|
||||
Online *bool `json:"online,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Events
|
||||
Event json.RawMessage `json:"event,omitempty"`
|
||||
// Disk
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Info json.RawMessage `json:"info,omitempty"`
|
||||
// Metrics
|
||||
CPU *float64 `json:"cpu,omitempty"`
|
||||
MemPct *float64 `json:"memPercent,omitempty"`
|
||||
MemUsed *int64 `json:"memUsed,omitempty"`
|
||||
MemTotal *int64 `json:"memTotal,omitempty"`
|
||||
CPUCount *int `json:"cpuCount,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker API response types (minimal, only what we need)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type containerInfo struct {
|
||||
ID string `json:"Id"`
|
||||
State string `json:"State"`
|
||||
}
|
||||
|
||||
type containerStats struct {
|
||||
CPUStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemCPUUsage uint64 `json:"system_cpu_usage"`
|
||||
OnlineCPUs int `json:"online_cpus"`
|
||||
} `json:"cpu_stats"`
|
||||
PrecpuStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemCPUUsage uint64 `json:"system_cpu_usage"`
|
||||
} `json:"precpu_stats"`
|
||||
MemoryStats struct {
|
||||
Usage uint64 `json:"usage"`
|
||||
Stats struct {
|
||||
InactiveFile uint64 `json:"inactive_file"`
|
||||
TotalInactiveFile uint64 `json:"total_inactive_file"`
|
||||
} `json:"stats"`
|
||||
} `json:"memory_stats"`
|
||||
}
|
||||
|
||||
type dockerInfo struct {
|
||||
MemTotal int64 `json:"MemTotal"`
|
||||
NCPU int `json:"NCPU"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statsConcurrency = 8 // Max parallel stats calls per environment
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type environment struct {
|
||||
id int
|
||||
name string
|
||||
connectionType string
|
||||
hawserToken string
|
||||
client *http.Client
|
||||
streamClient *http.Client
|
||||
transport *http.Transport
|
||||
streamTransport *http.Transport
|
||||
baseURL string
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context
|
||||
online bool
|
||||
statusReported bool // true after first env_status message sent
|
||||
}
|
||||
|
||||
// closeTransports releases idle connections held by the environment's HTTP transports.
|
||||
// Must be called when an environment is removed or reconfigured to prevent connection pool leaks.
|
||||
func (e *environment) closeTransports() {
|
||||
if e.transport != nil {
|
||||
e.transport.CloseIdleConnections()
|
||||
}
|
||||
if e.streamTransport != nil {
|
||||
e.streamTransport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
mu sync.Mutex
|
||||
envs map[int]*environment
|
||||
metricsInterval time.Duration
|
||||
eventMode string // "stream" or "poll"
|
||||
pollInterval time.Duration
|
||||
diskInterval time.Duration
|
||||
output *json.Encoder
|
||||
outputMu sync.Mutex
|
||||
}
|
||||
|
||||
func newManager(output *json.Encoder) *manager {
|
||||
return &manager{
|
||||
envs: make(map[int]*environment),
|
||||
metricsInterval: 30 * time.Second,
|
||||
eventMode: "stream",
|
||||
pollInterval: 60 * time.Second,
|
||||
diskInterval: 5 * time.Minute,
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) send(msg OutMessage) {
|
||||
m.outputMu.Lock()
|
||||
defer m.outputMu.Unlock()
|
||||
_ = m.output.Encode(msg)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
func float64Ptr(v float64) *float64 { return &v }
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
// drainAndClose discards a response body and closes it (for connection reuse).
|
||||
func drainAndClose(resp *http.Response) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker HTTP client construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Client, tp *http.Transport, stp *http.Transport, baseURL string, err error) {
|
||||
var transport *http.Transport
|
||||
var streamTransport *http.Transport
|
||||
|
||||
switch cfg.Type {
|
||||
case "socket":
|
||||
socketPath := cfg.SocketPath
|
||||
if socketPath == "" {
|
||||
socketPath = "/var/run/docker.sock"
|
||||
}
|
||||
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
}
|
||||
transport = &http.Transport{
|
||||
DialContext: dial,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: dial,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
IdleConnTimeout: 0,
|
||||
}
|
||||
baseURL = "http://localhost"
|
||||
|
||||
case "http":
|
||||
transport = &http.Transport{
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
IdleConnTimeout: 0,
|
||||
}
|
||||
baseURL = fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
case "https":
|
||||
tlsCfg, tlsErr := buildTLSConfig(cfg)
|
||||
if tlsErr != nil {
|
||||
return nil, nil, nil, nil, "", tlsErr
|
||||
}
|
||||
streamTLSCfg := tlsCfg.Clone()
|
||||
|
||||
transport = &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
TLSClientConfig: streamTLSCfg,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
IdleConnTimeout: 0,
|
||||
}
|
||||
baseURL = fmt.Sprintf("https://%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
default:
|
||||
return nil, nil, nil, nil, "", fmt.Errorf("unsupported connection type: %s", cfg.Type)
|
||||
}
|
||||
|
||||
client = &http.Client{Transport: transport, Timeout: 30 * time.Second}
|
||||
streamClient = &http.Client{Transport: streamTransport, Timeout: 0}
|
||||
return client, streamClient, transport, streamTransport, baseURL, nil
|
||||
}
|
||||
|
||||
func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) {
|
||||
tlsCfg := &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipVerify,
|
||||
ServerName: cfg.Host, // Explicit SNI for IP-based hosts
|
||||
}
|
||||
|
||||
if cfg.CA != "" {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM([]byte(cfg.CA)) {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate")
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
if cfg.Cert != "" && cfg.Key != "" {
|
||||
cert, err := tls.X509KeyPair([]byte(cfg.Cert), []byte(cfg.Key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse client cert/key: %w", err)
|
||||
}
|
||||
tlsCfg.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (e *environment) doRequest(ctx context.Context, method, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, e.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.hawserToken != "" {
|
||||
req.Header.Set("X-Hawser-Token", e.hawserToken)
|
||||
}
|
||||
return e.client.Do(req)
|
||||
}
|
||||
|
||||
func (e *environment) doStreamRequest(ctx context.Context, method, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, e.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.hawserToken != "" {
|
||||
req.Header.Set("X-Hawser-Token", e.hawserToken)
|
||||
}
|
||||
return e.streamClient.Do(req)
|
||||
}
|
||||
|
||||
func (e *environment) ping(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(ctx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
drainAndClose(resp)
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metrics collection goroutine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *manager) runMetrics(env *environment) {
|
||||
m.collectMetrics(env)
|
||||
|
||||
ticker := time.NewTicker(m.metricsInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.mu.Lock()
|
||||
interval := m.metricsInterval
|
||||
m.mu.Unlock()
|
||||
ticker.Reset(interval)
|
||||
m.collectMetrics(env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) collectMetrics(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !env.online || !env.statusReported {
|
||||
env.online = true
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
|
||||
}
|
||||
|
||||
// List running containers
|
||||
ctx, cancel := context.WithTimeout(env.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := env.doRequest(ctx, "GET", "/containers/json?all=false")
|
||||
if err != nil {
|
||||
m.send(OutMessage{Type: "error", EnvID: env.id, Error: fmt.Sprintf("list containers: %s", err)})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
var containers []containerInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to running containers only
|
||||
running := make([]containerInfo, 0, len(containers))
|
||||
for _, c := range containers {
|
||||
if c.State == "running" {
|
||||
running = append(running, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect stats per container (parallel, bounded concurrency)
|
||||
type statsResult struct {
|
||||
cpu float64
|
||||
mem uint64
|
||||
}
|
||||
results := make([]statsResult, len(running))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, statsConcurrency)
|
||||
|
||||
for i, c := range running {
|
||||
wg.Add(1)
|
||||
go func(idx int, id string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer sCancel()
|
||||
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
|
||||
if sErr != nil {
|
||||
return
|
||||
}
|
||||
defer sResp.Body.Close()
|
||||
|
||||
if sResp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, sResp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
var stats containerStats
|
||||
if json.NewDecoder(sResp.Body).Decode(&stats) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PrecpuStats.CPUUsage.TotalUsage)
|
||||
sysDelta := float64(stats.CPUStats.SystemCPUUsage - stats.PrecpuStats.SystemCPUUsage)
|
||||
cpuCount := stats.CPUStats.OnlineCPUs
|
||||
if cpuCount == 0 {
|
||||
cpuCount = 1
|
||||
}
|
||||
|
||||
var cpuPct float64
|
||||
if sysDelta > 0 && cpuDelta > 0 {
|
||||
cpuPct = (cpuDelta / sysDelta) * float64(cpuCount) * 100
|
||||
}
|
||||
|
||||
memUsage := stats.MemoryStats.Usage
|
||||
memCache := stats.MemoryStats.Stats.InactiveFile
|
||||
if memCache == 0 {
|
||||
memCache = stats.MemoryStats.Stats.TotalInactiveFile
|
||||
}
|
||||
actualMem := memUsage
|
||||
if memCache > 0 && memCache < memUsage {
|
||||
actualMem = memUsage - memCache
|
||||
}
|
||||
|
||||
results[idx] = statsResult{cpu: cpuPct, mem: actualMem}
|
||||
}(i, c.ID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var totalCPU float64
|
||||
var totalMem uint64
|
||||
for _, r := range results {
|
||||
totalCPU += r.cpu
|
||||
totalMem += r.mem
|
||||
}
|
||||
|
||||
// Get docker info for MemTotal and NCPU
|
||||
iCtx, iCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer iCancel()
|
||||
|
||||
var info dockerInfo
|
||||
iResp, iErr := env.doRequest(iCtx, "GET", "/info")
|
||||
if iErr == nil {
|
||||
defer iResp.Body.Close()
|
||||
if iResp.StatusCode/100 == 2 {
|
||||
json.NewDecoder(iResp.Body).Decode(&info)
|
||||
} else {
|
||||
io.Copy(io.Discard, iResp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
memTotal := info.MemTotal
|
||||
cpuCount := info.NCPU
|
||||
if cpuCount == 0 {
|
||||
cpuCount = 1
|
||||
}
|
||||
|
||||
normalizedCPU := totalCPU / float64(cpuCount)
|
||||
var memPct float64
|
||||
if memTotal > 0 {
|
||||
memPct = (float64(totalMem) / float64(memTotal)) * 100
|
||||
}
|
||||
|
||||
if !math.IsNaN(normalizedCPU) && !math.IsInf(normalizedCPU, 0) && memTotal > 0 {
|
||||
m.send(OutMessage{
|
||||
Type: "metrics",
|
||||
EnvID: env.id,
|
||||
CPU: float64Ptr(normalizedCPU),
|
||||
MemPct: float64Ptr(memPct),
|
||||
MemUsed: int64Ptr(int64(totalMem)),
|
||||
MemTotal: int64Ptr(memTotal),
|
||||
CPUCount: intPtr(cpuCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event streaming goroutine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *manager) runEvents(env *environment) {
|
||||
reconnectDelay := 5 * time.Second
|
||||
maxReconnectDelay := 60 * time.Second
|
||||
|
||||
// Reusable timer to avoid time.After leaks in select statements.
|
||||
// Stopped and drained between uses to prevent firing stale timers.
|
||||
delayTimer := time.NewTimer(0)
|
||||
if !delayTimer.Stop() {
|
||||
<-delayTimer.C
|
||||
}
|
||||
|
||||
waitOrCancel := func(d time.Duration) bool {
|
||||
delayTimer.Reset(d)
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
if !delayTimer.Stop() {
|
||||
<-delayTimer.C
|
||||
}
|
||||
return false
|
||||
case <-delayTimer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
mode := m.eventMode
|
||||
pollInterval := m.pollInterval
|
||||
m.mu.Unlock()
|
||||
|
||||
if mode == "poll" {
|
||||
m.pollEvents(env)
|
||||
if !waitOrCancel(pollInterval) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Stream mode
|
||||
if !env.ping(env.ctx) {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
}
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
if !env.online || !env.statusReported {
|
||||
env.online = true
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
|
||||
}
|
||||
reconnectDelay = 5 * time.Second
|
||||
|
||||
// Open event stream
|
||||
resp, err := env.doStreamRequest(env.ctx, "GET", "/events?type=container")
|
||||
if err != nil {
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
env.online = false
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: err.Error()})
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
drainAndClose(resp)
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read events line-by-line with a bounded buffer.
|
||||
// Docker events are newline-delimited JSON; using bufio.Scanner
|
||||
// avoids json.Decoder's unbounded internal buffer growth.
|
||||
//
|
||||
// Force-close the body on context cancellation so scanner.Scan()
|
||||
// unblocks. Without this, the goroutine can leak if the transport's
|
||||
// internal cancel watcher doesn't fire (Go runtime implementation detail).
|
||||
bodyDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
resp.Body.Close()
|
||||
case <-bodyDone:
|
||||
}
|
||||
}()
|
||||
|
||||
eventScanner := bufio.NewScanner(resp.Body)
|
||||
eventScanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 64KB initial, 1MB max
|
||||
for eventScanner.Scan() {
|
||||
if env.ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
line := eventScanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
// Validate JSON and forward as raw message
|
||||
if json.Valid(line) {
|
||||
m.send(OutMessage{
|
||||
Type: "container_event",
|
||||
EnvID: env.id,
|
||||
Event: json.RawMessage(append([]byte(nil), line...)),
|
||||
})
|
||||
}
|
||||
}
|
||||
close(bodyDone)
|
||||
resp.Body.Close()
|
||||
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Stream ended — reconnect
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
}
|
||||
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) pollEvents(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !env.online || !env.statusReported {
|
||||
env.online = true
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
since := now - 30
|
||||
|
||||
ctx, cancel := context.WithTimeout(env.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := env.doRequest(ctx, "GET", fmt.Sprintf("/events?type=container&since=%d&until=%d", since, now))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
pollScanner := bufio.NewScanner(resp.Body)
|
||||
pollScanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for pollScanner.Scan() {
|
||||
line := pollScanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if json.Valid(line) {
|
||||
m.send(OutMessage{
|
||||
Type: "container_event",
|
||||
EnvID: env.id,
|
||||
Event: json.RawMessage(append([]byte(nil), line...)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disk usage check goroutine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *manager) runDiskChecks(env *environment) {
|
||||
if os.Getenv("SKIP_DF_COLLECTION") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
initDelay := time.NewTimer(10 * time.Second)
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
if !initDelay.Stop() {
|
||||
<-initDelay.C
|
||||
}
|
||||
return
|
||||
case <-initDelay.C:
|
||||
}
|
||||
m.checkDisk(env)
|
||||
|
||||
ticker := time.NewTicker(m.diskInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.checkDisk(env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) checkDisk(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(env.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := env.doRequest(ctx, "GET", "/system/df")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB cap
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Also fetch /info for DriverStatus (percentage-based disk warnings)
|
||||
var infoBody json.RawMessage
|
||||
iCtx, iCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer iCancel()
|
||||
iResp, iErr := env.doRequest(iCtx, "GET", "/info")
|
||||
if iErr == nil {
|
||||
if iResp.StatusCode/100 == 2 {
|
||||
infoBody, _ = io.ReadAll(io.LimitReader(iResp.Body, 2*1024*1024)) // 2MB cap
|
||||
} else {
|
||||
io.Copy(io.Discard, iResp.Body)
|
||||
}
|
||||
iResp.Body.Close()
|
||||
}
|
||||
|
||||
m.send(OutMessage{
|
||||
Type: "disk_usage",
|
||||
EnvID: env.id,
|
||||
Data: json.RawMessage(body),
|
||||
Info: infoBody,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *manager) configure(msg InMessage) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if existing, ok := m.envs[msg.EnvID]; ok {
|
||||
existing.cancel()
|
||||
existing.closeTransports()
|
||||
delete(m.envs, msg.EnvID)
|
||||
}
|
||||
|
||||
if msg.Config == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if msg.ConnectionType == "hawser-edge" {
|
||||
return
|
||||
}
|
||||
|
||||
client, streamClient, transport, streamTransport, baseURL, err := buildClients(msg.Config)
|
||||
if err != nil {
|
||||
m.send(OutMessage{Type: "error", EnvID: msg.EnvID, Error: fmt.Sprintf("configure: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env := &environment{
|
||||
id: msg.EnvID,
|
||||
name: msg.Name,
|
||||
connectionType: msg.ConnectionType,
|
||||
hawserToken: msg.HawserToken,
|
||||
client: client,
|
||||
streamClient: streamClient,
|
||||
transport: transport,
|
||||
streamTransport: streamTransport,
|
||||
baseURL: baseURL,
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
m.envs[msg.EnvID] = env
|
||||
|
||||
go m.runMetrics(env)
|
||||
go m.runEvents(env)
|
||||
go m.runDiskChecks(env)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[collector] configured env %d (%s) type=%s base=%s\n", env.id, env.name, msg.ConnectionType, baseURL)
|
||||
}
|
||||
|
||||
func (m *manager) remove(envID int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if env, ok := m.envs[envID]; ok {
|
||||
env.cancel()
|
||||
env.closeTransports()
|
||||
delete(m.envs, envID)
|
||||
fmt.Fprintf(os.Stderr, "[collector] removed env %d\n", envID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) shutdown() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, env := range m.envs {
|
||||
env.cancel()
|
||||
env.closeTransports()
|
||||
delete(m.envs, id)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[collector] shutdown complete\n")
|
||||
}
|
||||
|
||||
func (m *manager) setMetricsInterval(ms int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if ms > 0 {
|
||||
m.metricsInterval = time.Duration(ms) * time.Millisecond
|
||||
fmt.Fprintf(os.Stderr, "[collector] metrics interval set to %dms\n", ms)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) setEventMode(mode string, pollMs int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if mode != "" {
|
||||
m.eventMode = mode
|
||||
}
|
||||
if pollMs > 0 {
|
||||
m.pollInterval = time.Duration(pollMs) * time.Millisecond
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[collector] event mode=%s pollInterval=%dms\n", m.eventMode, m.pollInterval/time.Millisecond)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stderr, "[collector] starting...\n")
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
mgr := newManager(encoder)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
fmt.Fprintf(os.Stderr, "[collector] received signal, shutting down\n")
|
||||
mgr.shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
mgr.send(OutMessage{Type: "ready"})
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 64KB initial, grows to 10MB if needed
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg InMessage
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[collector] invalid message: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "configure":
|
||||
mgr.configure(msg)
|
||||
case "remove":
|
||||
mgr.remove(msg.EnvID)
|
||||
case "set_metrics_interval":
|
||||
mgr.setMetricsInterval(msg.IntervalMs)
|
||||
case "set_event_mode":
|
||||
mgr.setEventMode(msg.Mode, msg.PollIntervalMs)
|
||||
case "shutdown":
|
||||
mgr.shutdown()
|
||||
os.Exit(0)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "[collector] unknown message type: %s\n", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[collector] stdin closed, exiting\n")
|
||||
mgr.shutdown()
|
||||
}
|
||||
|
||||
func minDuration(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+22
-17
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
"prebuild": "bunx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
|
||||
"build": "bunx --bun vite build && bun scripts/patch-build.ts && bun scripts/build-subprocesses.ts",
|
||||
"start": "bun ./build/index.js",
|
||||
"preview": "bun ./build/index.js",
|
||||
"prepare": "bunx --bun svelte-kit sync || echo ''",
|
||||
"check": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"dev": "npx vite dev",
|
||||
"prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
|
||||
"build": "npx vite build",
|
||||
"start": "node ./server.js",
|
||||
"preview": "node ./build/index.js",
|
||||
"prepare": "npx svelte-kit sync || echo ''",
|
||||
"check": "npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "bun test",
|
||||
"test:smoke": "bun test tests/api-smoke.test.ts",
|
||||
"test:containers": "bun test tests/container-lifecycle.test.ts",
|
||||
@@ -49,8 +49,8 @@
|
||||
"test:all": "bun test tests/",
|
||||
"test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts",
|
||||
"test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts",
|
||||
"test:e2e": "bunx playwright test tests/e2e/",
|
||||
"generate:legal": "bun scripts/generate-legal-pages.ts"
|
||||
"test:e2e": "npx playwright test tests/e2e/",
|
||||
"generate:legal": "node scripts/generate-legal-pages.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
@@ -71,11 +71,13 @@
|
||||
"@codemirror/view": "6.39.11",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lucide/lab": "^0.1.2",
|
||||
"argon2": "^0.41.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"codemirror": "6.0.2",
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"devalue": "5.6.3",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"hash-wasm": "4.12.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ldapts": "^8.1.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
@@ -83,25 +85,29 @@
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "0.9.69",
|
||||
"svelte-sonner": "1.0.7"
|
||||
"svelte-sonner": "1.0.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@layerstack/tailwind": "^1.0.1",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "7.0.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"bits-ui": "^2.15.4",
|
||||
"bits-ui": "2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"cytoscape": "^3.33.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
@@ -111,8 +117,7 @@
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "5.47.1",
|
||||
"svelte-adapter-bun": "1.0.1",
|
||||
"svelte": "5.53.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-easy-crop": "^5.0.0",
|
||||
"svelte-virtual-scroll-list": "^1.3.0",
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Build subprocess scripts as standalone bundles for production.
|
||||
*
|
||||
* Subprocesses run via Bun.spawn and need all dependencies bundled
|
||||
* since they can't access the SvelteKit build output's chunked modules.
|
||||
*/
|
||||
|
||||
const subprocesses = ['metrics-subprocess', 'event-subprocess'];
|
||||
|
||||
console.log('[build-subprocesses] Bundling subprocess scripts...');
|
||||
|
||||
for (const name of subprocesses) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`./src/lib/server/subprocesses/${name}.ts`],
|
||||
outdir: './build/subprocesses',
|
||||
target: 'bun',
|
||||
minify: false
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[build-subprocesses] Failed to bundle ${name}:`);
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[build-subprocesses] Bundled ${name}.js`);
|
||||
}
|
||||
|
||||
console.log('[build-subprocesses] Done');
|
||||
@@ -1,690 +0,0 @@
|
||||
/**
|
||||
* Post-build script to fix svelte-adapter-bun WebSocket issue
|
||||
* The adapter calls server.websocket() which doesn't exist in SvelteKit.
|
||||
*
|
||||
* IMPORTANT: Terminal WebSocket logic is shared with vite.config.ts
|
||||
* Core functions like resolveDockerTarget are defined in:
|
||||
* src/lib/server/ws-terminal-shared.ts
|
||||
*
|
||||
* When updating WebSocket terminal handling, update the shared module
|
||||
* and this file will use the same logic at build time.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
const BUILD_DIR = join(import.meta.dir, '../build');
|
||||
|
||||
async function patchHandler() {
|
||||
const handlerPath = join(BUILD_DIR, 'handler.js');
|
||||
const handlerFile = Bun.file(handlerPath);
|
||||
|
||||
if (!await handlerFile.exists()) {
|
||||
console.error('handler.js not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let content = await handlerFile.text();
|
||||
|
||||
// Replace broken server.websocket() call
|
||||
content = content.replace(
|
||||
'const websocket = server.websocket();',
|
||||
'const websocket = null;'
|
||||
);
|
||||
|
||||
// Add WebSocket upgrade detection before ssr handler
|
||||
const ssrIndex = content.indexOf('var ssr = async (request, bunServer) => {');
|
||||
if (ssrIndex > -1) {
|
||||
const upgradeCode = `
|
||||
var handleUpgrade = (request, bunServer) => {
|
||||
const url = new URL(request.url);
|
||||
const isUpgrade = request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
|
||||
request.headers.get('upgrade')?.toLowerCase() === 'websocket';
|
||||
if (!isUpgrade) return null;
|
||||
|
||||
// Handle terminal exec WebSocket
|
||||
if (url.pathname.includes('/api/containers/') && url.pathname.includes('/exec')) {
|
||||
const pathParts = url.pathname.split('/');
|
||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||
const containerId = pathParts[containerIdIndex];
|
||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||
const user = url.searchParams.get('user') || 'root';
|
||||
const envId = url.searchParams.get('envId') ? parseInt(url.searchParams.get('envId'), 10) : undefined;
|
||||
if (bunServer.upgrade(request, { data: { type: 'terminal', containerId, shell, user, envId } })) {
|
||||
return new Response(null, { status: 101 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Hawser Edge WebSocket
|
||||
if (url.pathname === '/api/hawser/connect') {
|
||||
if (bunServer.upgrade(request, { data: { type: 'hawser' } })) {
|
||||
return new Response(null, { status: 101 });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
`;
|
||||
content = content.slice(0, ssrIndex) + upgradeCode + content.slice(ssrIndex);
|
||||
}
|
||||
|
||||
// Modify handler to check for upgrade first
|
||||
content = content.replace(
|
||||
'return ssr(request, server2);',
|
||||
'const upgradeResponse = handleUpgrade(request, server2); if (upgradeResponse) return upgradeResponse; return ssr(request, server2);'
|
||||
);
|
||||
|
||||
await Bun.write(handlerPath, content);
|
||||
console.log('✓ Patched handler.js');
|
||||
}
|
||||
|
||||
async function patchIndex() {
|
||||
const indexPath = join(BUILD_DIR, 'index.js');
|
||||
const indexFile = Bun.file(indexPath);
|
||||
|
||||
if (!await indexFile.exists()) {
|
||||
console.error('index.js not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let content = await indexFile.text();
|
||||
|
||||
const wsHandler = `
|
||||
import { existsSync as _existsSync, readFileSync as _readFileSync } from 'fs';
|
||||
import { homedir as _homedir } from 'os';
|
||||
import { Database as _Database } from 'bun:sqlite';
|
||||
import { SQL as _SQL } from 'bun';
|
||||
import { join as _join } from 'path';
|
||||
import { createDecipheriv as _createDecipheriv } from 'node:crypto';
|
||||
|
||||
// Encryption/decryption for sensitive fields
|
||||
const _ENCRYPTED_PREFIX = 'enc:v1:';
|
||||
const _IV_LENGTH = 12;
|
||||
const _AUTH_TAG_LENGTH = 16;
|
||||
let _encryptionKey = null;
|
||||
|
||||
function _getEncryptionKey() {
|
||||
if (_encryptionKey) return _encryptionKey;
|
||||
const dataDir = process.env.DATA_DIR || _join(process.cwd(), 'data');
|
||||
const keyPath = _join(dataDir, '.encryption_key');
|
||||
const envKey = process.env.ENCRYPTION_KEY;
|
||||
if (_existsSync(keyPath)) {
|
||||
try {
|
||||
_encryptionKey = _readFileSync(keyPath);
|
||||
return _encryptionKey;
|
||||
} catch {}
|
||||
}
|
||||
if (envKey) {
|
||||
try {
|
||||
_encryptionKey = Buffer.from(envKey, 'base64');
|
||||
return _encryptionKey;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _decrypt(value) {
|
||||
if (!value || !value.startsWith(_ENCRYPTED_PREFIX)) return value;
|
||||
const key = _getEncryptionKey();
|
||||
if (!key) { console.error('[WS] Cannot decrypt: no encryption key'); return value; }
|
||||
try {
|
||||
const payload = value.substring(_ENCRYPTED_PREFIX.length);
|
||||
const combined = Buffer.from(payload, 'base64');
|
||||
if (combined.length < _IV_LENGTH + _AUTH_TAG_LENGTH + 1) return value;
|
||||
const iv = combined.subarray(0, _IV_LENGTH);
|
||||
const authTag = combined.subarray(_IV_LENGTH, _IV_LENGTH + _AUTH_TAG_LENGTH);
|
||||
const ciphertext = combined.subarray(_IV_LENGTH + _AUTH_TAG_LENGTH);
|
||||
const decipher = _createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
} catch (e) { console.error('[WS] Decryption failed:', e); return value; }
|
||||
}
|
||||
|
||||
// Database connection (supports both SQLite and PostgreSQL)
|
||||
let _db = null;
|
||||
let _isPostgres = false;
|
||||
function _getDb() {
|
||||
if (!_db) {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (dbUrl && (dbUrl.startsWith('postgres://') || dbUrl.startsWith('postgresql://'))) {
|
||||
_db = new _SQL(dbUrl);
|
||||
_isPostgres = true;
|
||||
} else {
|
||||
const _dbPath = process.env.DATA_DIR ? _join(process.env.DATA_DIR, 'db', 'dockhand.db') : _join(process.cwd(), 'data', 'db', 'dockhand.db');
|
||||
if (_existsSync(_dbPath)) {
|
||||
_db = new _Database(_dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
async function _getEnvironment(id) {
|
||||
const db = _getDb();
|
||||
if (!db) return null;
|
||||
let row;
|
||||
if (_isPostgres) {
|
||||
const result = await db.unsafe('SELECT * FROM environments WHERE id = $1', [id]);
|
||||
row = result[0];
|
||||
} else {
|
||||
row = db.prepare('SELECT * FROM environments WHERE id = ?').get(id);
|
||||
}
|
||||
return row ? { ...row, is_local: Boolean(row.is_local), connection_type: row.connection_type, hawser_token: row.hawser_token } : null;
|
||||
}
|
||||
|
||||
function detectDockerSocket() {
|
||||
if (process.env.DOCKER_SOCKET && _existsSync(process.env.DOCKER_SOCKET)) return process.env.DOCKER_SOCKET;
|
||||
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
|
||||
const p = process.env.DOCKER_HOST.replace('unix://', '');
|
||||
if (_existsSync(p)) return p;
|
||||
}
|
||||
for (const s of ['/var/run/docker.sock', _homedir() + '/.docker/run/docker.sock', _homedir() + '/.orbstack/run/docker.sock', '/run/docker.sock']) {
|
||||
if (_existsSync(s)) return s;
|
||||
}
|
||||
return '/var/run/docker.sock';
|
||||
}
|
||||
const dockerSocketPath = detectDockerSocket();
|
||||
console.log('Detected Docker socket at:', dockerSocketPath);
|
||||
|
||||
const dockerStreams = new Map();
|
||||
let _wsConnCounter = 0;
|
||||
|
||||
async function _getDockerTarget(envId) {
|
||||
if (!envId) return { type: 'unix', socket: dockerSocketPath };
|
||||
const env = await _getEnvironment(envId);
|
||||
if (!env) return { type: 'unix', socket: dockerSocketPath };
|
||||
// Check for socket connection type (local Unix socket)
|
||||
if (env.is_local || env.connection_type === 'socket' || !env.connection_type) {
|
||||
return { type: 'unix', socket: env.socket_path || dockerSocketPath };
|
||||
}
|
||||
if (env.connection_type === 'hawser-edge') return { type: 'hawser-edge', environmentId: envId };
|
||||
// Build TLS config if using HTTPS
|
||||
const protocol = env.protocol || 'http';
|
||||
const useTls = protocol === 'https';
|
||||
let tls = null;
|
||||
if (useTls) {
|
||||
tls = {
|
||||
rejectUnauthorized: !env.tls_skip_verify,
|
||||
ca: env.tls_ca || undefined,
|
||||
cert: env.tls_cert || undefined,
|
||||
// tls_key is encrypted - decrypt it
|
||||
key: _decrypt(env.tls_key) || undefined
|
||||
};
|
||||
}
|
||||
// hawser_token is also encrypted
|
||||
const hawserToken = env.connection_type === 'hawser-standard' && env.hawser_token
|
||||
? _decrypt(env.hawser_token) || undefined
|
||||
: undefined;
|
||||
return {
|
||||
type: useTls ? 'tls' : 'tcp',
|
||||
host: env.host,
|
||||
port: env.port || 2375,
|
||||
hawserToken,
|
||||
tls
|
||||
};
|
||||
}
|
||||
|
||||
async function createExec(containerId, cmd, user, target) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const fetchOpts = {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: cmd, User: user })
|
||||
};
|
||||
let url;
|
||||
if (target.type === 'unix') {
|
||||
url = 'http://localhost/containers/' + containerId + '/exec';
|
||||
fetchOpts.unix = target.socket;
|
||||
} else {
|
||||
const protocol = target.type === 'tls' ? 'https' : 'http';
|
||||
url = protocol + '://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec';
|
||||
if (target.hawserToken) headers['X-Hawser-Token'] = target.hawserToken;
|
||||
if (target.tls) {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized
|
||||
};
|
||||
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
|
||||
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
|
||||
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
}
|
||||
const res = await fetch(url, fetchOpts);
|
||||
if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text()));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function resizeExec(execId, cols, rows, target) {
|
||||
try {
|
||||
const fetchOpts = { method: 'POST' };
|
||||
let url;
|
||||
if (target.type === 'unix') {
|
||||
url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
|
||||
fetchOpts.unix = target.socket;
|
||||
} else {
|
||||
const protocol = target.type === 'tls' ? 'https' : 'http';
|
||||
url = protocol + '://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
|
||||
if (target.hawserToken) fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken };
|
||||
if (target.tls) {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized
|
||||
};
|
||||
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
|
||||
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
|
||||
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
}
|
||||
await fetch(url, fetchOpts);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ============ Hawser Edge Support ============
|
||||
// Global edge connections map (shared with hawser.ts via globalThis)
|
||||
if (!globalThis.__hawserEdgeConnections) globalThis.__hawserEdgeConnections = new Map();
|
||||
const _edgeConnections = globalThis.__hawserEdgeConnections;
|
||||
|
||||
// Map WebSocket to environmentId for quick lookup
|
||||
const _wsToEnvId = new Map();
|
||||
|
||||
// Edge exec sessions (execId -> frontend WebSocket)
|
||||
const _edgeExecSessions = new Map();
|
||||
|
||||
// Validate Hawser token against database
|
||||
async function _validateHawserToken(token) {
|
||||
const db = _getDb();
|
||||
if (!db) return { valid: false };
|
||||
let tokens;
|
||||
if (_isPostgres) {
|
||||
tokens = await db.unsafe('SELECT * FROM hawser_tokens WHERE is_active = true');
|
||||
} else {
|
||||
tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all();
|
||||
}
|
||||
for (const t of tokens) {
|
||||
try {
|
||||
const isValid = await Bun.password.verify(token, t.token);
|
||||
if (isValid) {
|
||||
if (_isPostgres) {
|
||||
await db.unsafe('UPDATE hawser_tokens SET last_used = NOW() WHERE id = $1', [t.id]);
|
||||
} else {
|
||||
db.prepare('UPDATE hawser_tokens SET last_used = datetime(\\'now\\') WHERE id = ?').run(t.id);
|
||||
}
|
||||
return { valid: true, environmentId: t.environment_id, tokenId: t.id };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Update environment status in database
|
||||
async function _updateEnvStatus(envId, conn) {
|
||||
const db = _getDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
if (conn) {
|
||||
if (_isPostgres) {
|
||||
await db.unsafe('UPDATE environments SET hawser_last_seen = NOW(), hawser_agent_id = $1, hawser_agent_name = $2, hawser_version = $3, hawser_capabilities = $4 WHERE id = $5',
|
||||
[conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId]);
|
||||
} else {
|
||||
db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\'), hawser_agent_id = ?, hawser_agent_name = ?, hawser_version = ?, hawser_capabilities = ? WHERE id = ?')
|
||||
.run(conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId);
|
||||
}
|
||||
} else {
|
||||
if (_isPostgres) {
|
||||
await db.unsafe('UPDATE environments SET hawser_last_seen = NOW() WHERE id = $1', [envId]);
|
||||
} else {
|
||||
db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\') WHERE id = ?').run(envId);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle Hawser Edge protocol messages
|
||||
async function _handleHawserMessage(ws, msg) {
|
||||
if (msg.type === 'hello') {
|
||||
console.log('[Hawser] Hello from agent:', msg.agentName, '(' + msg.agentId + ')');
|
||||
const validation = await _validateHawserToken(msg.token);
|
||||
if (!validation.valid) {
|
||||
console.log('[Hawser] Invalid token');
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
const envId = validation.environmentId;
|
||||
const existing = _edgeConnections.get(envId);
|
||||
if (existing) {
|
||||
const pendingCount = existing.pendingRequests.size;
|
||||
const streamCount = existing.pendingStreamRequests.size;
|
||||
console.log('[Hawser] Replacing existing connection for env', envId, '- rejecting', pendingCount, 'pending requests and', streamCount, 'stream requests');
|
||||
// Reject all pending requests before closing
|
||||
for (const [requestId, pending] of existing.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Connection replaced by new agent'));
|
||||
}
|
||||
for (const [requestId, pending] of existing.pendingStreamRequests) {
|
||||
pending.onEnd?.('Connection replaced by new agent');
|
||||
}
|
||||
existing.pendingRequests.clear();
|
||||
existing.pendingStreamRequests.clear();
|
||||
existing.ws.close(1000, 'Replaced');
|
||||
_wsToEnvId.delete(existing.ws);
|
||||
}
|
||||
const conn = {
|
||||
ws, environmentId: envId, agentId: msg.agentId, agentName: msg.agentName,
|
||||
agentVersion: msg.version || 'unknown', dockerVersion: msg.dockerVersion || 'unknown',
|
||||
hostname: msg.hostname || 'unknown', capabilities: msg.capabilities || [],
|
||||
connectedAt: new Date(), lastHeartbeat: new Date(),
|
||||
pendingRequests: new Map(), pendingStreamRequests: new Map(),
|
||||
pingInterval: null
|
||||
};
|
||||
_edgeConnections.set(envId, conn);
|
||||
_wsToEnvId.set(ws, envId);
|
||||
await _updateEnvStatus(envId, conn);
|
||||
ws.send(JSON.stringify({ type: 'welcome', environmentId: envId, message: 'Connected to Dockhand' }));
|
||||
// Start server-side ping interval to keep connection alive through Traefik/proxies (5s)
|
||||
conn.pingInterval = setInterval(() => {
|
||||
try { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); }
|
||||
catch { if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } }
|
||||
}, 5000);
|
||||
console.log('[Hawser] Agent', msg.agentName, 'connected for env', envId);
|
||||
} else if (msg.type === 'ping') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); }
|
||||
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
||||
} else if (msg.type === 'pong') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); }
|
||||
} else if (msg.type === 'response') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (!envId) {
|
||||
console.warn('[Hawser] Response from unknown WebSocket, requestId=' + msg.requestId);
|
||||
return;
|
||||
}
|
||||
const conn = _edgeConnections.get(envId);
|
||||
if (conn) {
|
||||
const pending = conn.pendingRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
conn.pendingRequests.delete(msg.requestId);
|
||||
pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, body: msg.body || '', isBinary: msg.isBinary || false });
|
||||
} else {
|
||||
console.warn('[Hawser] Response for unknown request ' + msg.requestId + ' on env ' + envId);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'stream') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (!envId) {
|
||||
console.warn('[Hawser] Stream data from unknown WebSocket, requestId=' + msg.requestId);
|
||||
return;
|
||||
}
|
||||
const conn = _edgeConnections.get(envId);
|
||||
if (conn?.pendingStreamRequests) {
|
||||
const pending = conn.pendingStreamRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
pending.onData(msg.data, msg.stream);
|
||||
} else {
|
||||
console.warn('[Hawser] Stream data for unknown request ' + msg.requestId + ' on env ' + envId);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'stream_end') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (!envId) {
|
||||
console.warn('[Hawser] Stream end from unknown WebSocket, requestId=' + msg.requestId);
|
||||
return;
|
||||
}
|
||||
const conn = _edgeConnections.get(envId);
|
||||
if (conn?.pendingStreamRequests) {
|
||||
const pending = conn.pendingStreamRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
conn.pendingStreamRequests.delete(msg.requestId);
|
||||
pending.onEnd(msg.reason);
|
||||
} else {
|
||||
console.warn('[Hawser] Stream end for unknown request ' + msg.requestId + ' on env ' + envId);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'exec_ready') {
|
||||
const session = _edgeExecSessions.get(msg.execId);
|
||||
if (session?.ws?.readyState === 1) console.log('[Hawser] Exec ready:', msg.execId);
|
||||
} else if (msg.type === 'exec_output') {
|
||||
const session = _edgeExecSessions.get(msg.execId);
|
||||
if (session?.ws?.readyState === 1) {
|
||||
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
|
||||
session.ws.send(JSON.stringify({ type: 'output', data }));
|
||||
}
|
||||
} else if (msg.type === 'exec_end') {
|
||||
const session = _edgeExecSessions.get(msg.execId);
|
||||
if (session) {
|
||||
console.log('[Hawser] Exec ended:', msg.execId);
|
||||
if (session.ws?.readyState === 1) { session.ws.send(JSON.stringify({ type: 'exit' })); session.ws.close(); }
|
||||
_edgeExecSessions.delete(msg.execId);
|
||||
}
|
||||
} else if (msg.type === 'container_event') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (envId && msg.event) {
|
||||
// Call the global handler registered by hawser.ts
|
||||
if (globalThis.__hawserHandleContainerEvent) {
|
||||
globalThis.__hawserHandleContainerEvent(envId, msg.event).catch((err) => {
|
||||
console.error('[Hawser] Error handling container event:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'metrics') {
|
||||
// Metrics from agent - save to database for dashboard graphs
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (envId && msg.metrics) {
|
||||
if (globalThis.__hawserHandleMetrics) {
|
||||
globalThis.__hawserHandleMetrics(envId, msg.metrics).catch((err) => {
|
||||
console.error('[Hawser] Error saving metrics:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose send function for hawser.ts module
|
||||
globalThis.__hawserSendMessage = (envId, message) => {
|
||||
const conn = _edgeConnections.get(envId);
|
||||
if (!conn?.ws) return false;
|
||||
try { conn.ws.send(message); return true; } catch { return false; }
|
||||
};
|
||||
|
||||
// ============ Combined WebSocket Handler ============
|
||||
const combinedWebsocket = {
|
||||
async open(ws) {
|
||||
const connType = ws.data?.type;
|
||||
|
||||
// Hawser Edge connection - wait for hello message
|
||||
if (connType === 'hawser') {
|
||||
console.log('[Hawser] New connection pending authentication');
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal connection
|
||||
const connId = 'ws-' + (++_wsConnCounter);
|
||||
ws.data = ws.data || {};
|
||||
ws.data.connId = connId;
|
||||
const { containerId, shell, user, envId } = ws.data;
|
||||
if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; }
|
||||
const target = await _getDockerTarget(envId);
|
||||
console.log('[Terminal WS] Target:', JSON.stringify({ type: target.type, host: target.host, port: target.port, hasTls: !!target.tls, hasCa: !!target.tls?.ca, hasCert: !!target.tls?.cert, hasKey: !!target.tls?.key }));
|
||||
|
||||
// Handle Hawser Edge terminal
|
||||
if (target.type === 'hawser-edge') {
|
||||
const conn = _edgeConnections.get(target.environmentId);
|
||||
if (!conn) { ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' })); ws.close(); return; }
|
||||
const execId = crypto.randomUUID();
|
||||
_edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId });
|
||||
ws.data.edgeExecId = execId;
|
||||
conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Terminal WS] Creating exec for container:', containerId);
|
||||
const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target);
|
||||
console.log('[Terminal WS] Exec created:', exec?.Id);
|
||||
const execId = exec.Id;
|
||||
let dockerStream;
|
||||
let headersStripped = false;
|
||||
let isChunked = false;
|
||||
const socketHandler = {
|
||||
data(socket, data) {
|
||||
if (ws.readyState === 1) {
|
||||
let text = new TextDecoder().decode(data);
|
||||
if (!headersStripped) {
|
||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) isChunked = true;
|
||||
const i = text.indexOf('\\r\\n\\r\\n');
|
||||
if (i > -1) { text = text.slice(i + 4); headersStripped = true; }
|
||||
else if (text.startsWith('HTTP/')) return;
|
||||
}
|
||||
if (isChunked && text) text = text.replace(/^[0-9a-fA-F]+\\r\\n/gm, '').replace(/\\r\\n$/g, '');
|
||||
if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||
}
|
||||
},
|
||||
close() { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'exit' })); ws.close(); } },
|
||||
error(socket, error) {
|
||||
console.error('[Terminal WS] Socket error:', error?.message || error);
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'error', message: 'Connection error: ' + (error?.message || 'Unknown error') }));
|
||||
},
|
||||
connectError(socket, error) {
|
||||
console.error('[Terminal WS] Connect error:', error?.message || error);
|
||||
if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'error', message: 'Failed to connect: ' + (error?.message || 'Unknown error') })); ws.close(); }
|
||||
},
|
||||
open(socket) {
|
||||
const body = JSON.stringify({ Detach: false, Tty: true });
|
||||
const tokenHeader = (target.type === 'tcp' || target.type === 'tls') && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : '';
|
||||
// Use actual host for proper routing through reverse proxies like Caddy
|
||||
const host = target.host || 'localhost';
|
||||
socket.write('POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: ' + host + '\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: ' + body.length + '\\r\\n\\r\\n' + body);
|
||||
}
|
||||
};
|
||||
if (target.type === 'unix') {
|
||||
dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler });
|
||||
} else {
|
||||
const connectOpts = { hostname: target.host, port: target.port, socket: socketHandler };
|
||||
if (target.tls) {
|
||||
connectOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized
|
||||
};
|
||||
if (target.tls.ca) connectOpts.tls.ca = [target.tls.ca];
|
||||
if (target.tls.cert) connectOpts.tls.cert = [target.tls.cert];
|
||||
if (target.tls.key) connectOpts.tls.key = target.tls.key;
|
||||
}
|
||||
console.log('[Terminal WS] Connecting to:', connectOpts.hostname, connectOpts.port, 'TLS:', !!connectOpts.tls);
|
||||
dockerStream = await Bun.connect(connectOpts);
|
||||
console.log('[Terminal WS] Connected!');
|
||||
}
|
||||
dockerStreams.set(connId, { stream: dockerStream, execId, target });
|
||||
} catch (e) { console.error('[Terminal WS] Error:', e); ws.send(JSON.stringify({ type: 'error', message: e.message })); ws.close(); }
|
||||
},
|
||||
async message(ws, message) {
|
||||
const connType = ws.data?.type;
|
||||
|
||||
// Hawser Edge message
|
||||
if (connType === 'hawser') {
|
||||
try {
|
||||
let msgStr = typeof message === 'string' ? message : message instanceof ArrayBuffer ? new TextDecoder().decode(message) : Buffer.isBuffer(message) ? message.toString('utf-8') : new TextDecoder().decode(new Uint8Array(message));
|
||||
const msg = JSON.parse(msgStr);
|
||||
await _handleHawserMessage(ws, msg);
|
||||
} catch (e) {
|
||||
console.error('[Hawser] Error:', e.message);
|
||||
ws.send(JSON.stringify({ type: 'error', error: e.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Edge exec session input
|
||||
const edgeExecId = ws.data?.edgeExecId;
|
||||
if (edgeExecId) {
|
||||
const session = _edgeExecSessions.get(edgeExecId);
|
||||
if (session) {
|
||||
const conn = _edgeConnections.get(session.environmentId);
|
||||
if (conn) {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
if (msg.type === 'input') conn.ws.send(JSON.stringify({ type: 'exec_input', execId: edgeExecId, data: Buffer.from(msg.data).toString('base64') }));
|
||||
else if (msg.type === 'resize') conn.ws.send(JSON.stringify({ type: 'exec_resize', execId: edgeExecId, cols: msg.cols, rows: msg.rows }));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal message
|
||||
const connId = ws.data?.connId;
|
||||
if (!connId) return;
|
||||
const d = dockerStreams.get(connId);
|
||||
if (!d) return;
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
if (msg.type === 'input' && d.stream) d.stream.write(msg.data);
|
||||
else if (msg.type === 'resize' && d.execId) resizeExec(d.execId, msg.cols, msg.rows, d.target);
|
||||
} catch { if (d.stream) d.stream.write(message); }
|
||||
},
|
||||
close(ws) {
|
||||
const connType = ws.data?.type;
|
||||
|
||||
// Hawser Edge disconnection
|
||||
if (connType === 'hawser') {
|
||||
const envId = _wsToEnvId.get(ws);
|
||||
if (envId) {
|
||||
const conn = _edgeConnections.get(envId);
|
||||
if (conn) {
|
||||
console.log('[Hawser] Agent disconnected:', conn.agentId);
|
||||
// Clear server-side ping interval
|
||||
if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; }
|
||||
for (const [, p] of conn.pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Connection closed')); }
|
||||
for (const [, p] of conn.pendingStreamRequests) { p.onEnd('Connection closed'); }
|
||||
_edgeConnections.delete(envId);
|
||||
_updateEnvStatus(envId, null);
|
||||
}
|
||||
_wsToEnvId.delete(ws);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Edge exec session close
|
||||
const edgeExecId = ws.data?.edgeExecId;
|
||||
if (edgeExecId) {
|
||||
const session = _edgeExecSessions.get(edgeExecId);
|
||||
if (session) {
|
||||
const conn = _edgeConnections.get(session.environmentId);
|
||||
if (conn) conn.ws.send(JSON.stringify({ type: 'exec_end', execId: edgeExecId, reason: 'user_closed' }));
|
||||
_edgeExecSessions.delete(edgeExecId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal close
|
||||
const connId = ws.data?.connId;
|
||||
if (!connId) return;
|
||||
const d = dockerStreams.get(connId);
|
||||
if (d?.stream) d.stream.end();
|
||||
dockerStreams.delete(connId);
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const insertPoint = content.indexOf('var path = env(');
|
||||
if (insertPoint > -1) {
|
||||
content = content.slice(0, insertPoint) + wsHandler + content.slice(insertPoint);
|
||||
}
|
||||
|
||||
content = content.replace(
|
||||
'var { fetch: handlerFetch, websocket } = getHandler();',
|
||||
'var { fetch: handlerFetch, websocket: _ } = getHandler(); var websocket = combinedWebsocket;'
|
||||
);
|
||||
|
||||
await Bun.write(indexPath, content);
|
||||
console.log('✓ Patched index.js');
|
||||
}
|
||||
|
||||
console.log('Patching build...');
|
||||
await patchHandler();
|
||||
await patchIndex();
|
||||
console.log('✓ Done');
|
||||
+112
-44
@@ -9,10 +9,74 @@ import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||
import { detectHostDataDir } from '$lib/server/host-path';
|
||||
import { listContainers, removeContainer } from '$lib/server/docker';
|
||||
import { migrateCredentials } from '$lib/server/encryption';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { rmSync, readdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
|
||||
|
||||
// Content types worth compressing
|
||||
const COMPRESSIBLE_TYPES = [
|
||||
'application/json',
|
||||
'text/html',
|
||||
'text/plain',
|
||||
'text/css',
|
||||
'application/javascript',
|
||||
'text/javascript',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
// Minimum response size to bother compressing (1KB)
|
||||
const MIN_COMPRESS_SIZE = 1024;
|
||||
|
||||
function shouldCompress(request: Request, response: Response): boolean {
|
||||
const acceptEncoding = request.headers.get('accept-encoding') || '';
|
||||
if (!acceptEncoding.includes('gzip')) return false;
|
||||
|
||||
if (response.headers.has('content-encoding')) return false;
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/event-stream')) return false;
|
||||
if (contentType.includes('octet-stream')) return false;
|
||||
if (contentType.startsWith('image/') && !contentType.includes('svg')) return false;
|
||||
|
||||
const isCompressible = COMPRESSIBLE_TYPES.some(type => contentType.includes(type));
|
||||
if (!isCompressible) return false;
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) < MIN_COMPRESS_SIZE) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function compressResponse(request: Request, response: Response): Promise<Response> {
|
||||
if (!shouldCompress(request, response)) return response;
|
||||
|
||||
const body = await response.arrayBuffer();
|
||||
if (body.byteLength < MIN_COMPRESS_SIZE) return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
|
||||
const gzipBefore = rssBeforeOp();
|
||||
const compressed = gzipSync(new Uint8Array(body));
|
||||
rssAfterOp('gzip', gzipBefore);
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('content-encoding', 'gzip');
|
||||
headers.set('vary', 'Accept-Encoding');
|
||||
headers.delete('content-length');
|
||||
|
||||
return new Response(compressed, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup orphaned scanner version containers from previous runs
|
||||
async function cleanupOrphanedScannerContainers() {
|
||||
@@ -98,11 +162,12 @@ if (!initialized) {
|
||||
cleanupOrphanedScannerContainers().catch(err => {
|
||||
console.error('Failed to cleanup orphaned scanner containers:', err);
|
||||
});
|
||||
// Start background subprocesses for metrics and event collection (isolated processes)
|
||||
// Start background subprocesses for metrics and event collection (worker thread)
|
||||
startSubprocesses().catch(err => {
|
||||
console.error('Failed to start background subprocesses:', err);
|
||||
});
|
||||
startScheduler(); // Start unified scheduler for auto-updates and git syncs (async)
|
||||
startRssTracker(); // Start RSS memory tracking (no-op unless MEMORY_MONITOR=true)
|
||||
|
||||
// Check license expiry on startup and then daily (with HMR guard)
|
||||
checkLicenseExpiry().catch(err => {
|
||||
@@ -119,6 +184,7 @@ if (!initialized) {
|
||||
// Graceful shutdown handling
|
||||
const shutdown = async () => {
|
||||
console.log('[Server] Shutting down...');
|
||||
stopRssTracker();
|
||||
await stopSubprocesses();
|
||||
process.exit(0);
|
||||
};
|
||||
@@ -175,55 +241,57 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// WebSocket upgrade for terminal connections is handled by the build patch (scripts/patch-build.ts)
|
||||
// This is necessary because svelte-adapter-bun expects server.websocket() which doesn't exist in SvelteKit
|
||||
const httpBefore = rssBeforeOp();
|
||||
try {
|
||||
// Check if auth is enabled
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
// Check if auth is enabled
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
// If auth is disabled, allow everything (app works as before)
|
||||
if (!authEnabled) {
|
||||
event.locals.user = null;
|
||||
event.locals.authEnabled = false;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Auth is enabled - check session
|
||||
const user = await validateSession(event.cookies);
|
||||
event.locals.user = user;
|
||||
event.locals.authEnabled = true;
|
||||
|
||||
// Public paths don't require authentication
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// If not authenticated
|
||||
if (!user) {
|
||||
// Special case: allow user creation when auth is enabled but no admin exists yet
|
||||
// This enables the first admin user to be created during initial setup
|
||||
const noAdminSetupMode = !(await hasAdminUser());
|
||||
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||
return resolve(event);
|
||||
// If auth is disabled, allow everything (app works as before)
|
||||
if (!authEnabled) {
|
||||
event.locals.user = null;
|
||||
event.locals.authEnabled = false;
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
// API routes return 401
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
// Auth is enabled - check session
|
||||
const user = await validateSession(event.cookies);
|
||||
event.locals.user = user;
|
||||
event.locals.authEnabled = true;
|
||||
|
||||
// Public paths don't require authentication
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
// UI routes redirect to login
|
||||
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
|
||||
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||
}
|
||||
// If not authenticated
|
||||
if (!user) {
|
||||
// Special case: allow user creation when auth is enabled but no admin exists yet
|
||||
// This enables the first admin user to be created during initial setup
|
||||
const noAdminSetupMode = !(await hasAdminUser());
|
||||
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
// API routes return 401
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// UI routes redirect to login
|
||||
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
|
||||
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||
}
|
||||
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
} finally {
|
||||
rssAfterOp('http', httpBefore);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleError: HandleServerError = ({ error, event }) => {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
let successCount = $state(0);
|
||||
let failCount = $state(0);
|
||||
let cancelledCount = $state(0);
|
||||
let abortController: AbortController | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
// Progress calculation
|
||||
const progress = $derived(() => {
|
||||
@@ -88,9 +88,7 @@
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
async function startOperation() {
|
||||
@@ -106,20 +104,13 @@
|
||||
successCount = 0;
|
||||
failCount = 0;
|
||||
cancelledCount = 0;
|
||||
|
||||
abortController = new AbortController();
|
||||
cancelled = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
entityType,
|
||||
items,
|
||||
options
|
||||
}),
|
||||
signal: abortController.signal
|
||||
body: JSON.stringify({ operation, entityType, items, options })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -127,52 +118,44 @@
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
const data = await response.json();
|
||||
const { jobId } = data;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
// Poll job for progress events
|
||||
let cursor = 0;
|
||||
while (!cancelled) {
|
||||
const jobRes = await fetch(`/api/jobs/${jobId}`);
|
||||
if (!jobRes.ok) break;
|
||||
const job = await jobRes.json();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const event: BatchEvent = JSON.parse(line.slice(6));
|
||||
handleEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
// Process new lines since last poll
|
||||
const newLines = job.lines.slice(cursor);
|
||||
cursor = job.lines.length;
|
||||
for (const line of newLines) {
|
||||
handleEvent(line.data as BatchEvent);
|
||||
}
|
||||
|
||||
if (job.status !== 'running') break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
// User cancelled - mark remaining as cancelled
|
||||
let cancelled = 0;
|
||||
|
||||
if (cancelled) {
|
||||
// Mark remaining items as cancelled
|
||||
let cancelCount = 0;
|
||||
itemStates = itemStates.map(item => {
|
||||
if (item.status === 'pending' || item.status === 'processing') {
|
||||
cancelled++;
|
||||
cancelCount++;
|
||||
return { ...item, status: 'cancelled' as ItemStatus };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
cancelledCount = cancelled;
|
||||
} else {
|
||||
console.error('Batch operation error:', error);
|
||||
cancelledCount = cancelCount;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Batch operation error:', error);
|
||||
} finally {
|
||||
isRunning = false;
|
||||
isComplete = true;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +178,7 @@
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
cancelled = true;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
|
||||
interface LayerProgress {
|
||||
id: string;
|
||||
@@ -168,33 +169,10 @@
|
||||
throw new Error('Failed to start pull');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handlePullProgress(data);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
const { jobId } = await response.json();
|
||||
await watchJob(jobId, (line) => {
|
||||
handlePullProgress(line.data as any);
|
||||
});
|
||||
|
||||
if (status === 'pulling') {
|
||||
duration = Date.now() - startTime;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { tick, onMount } from 'svelte';
|
||||
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Upload } from 'lucide-svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
|
||||
type PushStatus = 'idle' | 'pushing' | 'complete' | 'error';
|
||||
|
||||
@@ -144,39 +145,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SSE stream
|
||||
const reader = pushResponse.body?.getReader();
|
||||
if (!reader) {
|
||||
errorMessage = 'No response body';
|
||||
status = 'error';
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
const { jobId } = await pushResponse.json();
|
||||
await watchJob(jobId, (line) => {
|
||||
handlePushProgress(line.data as any);
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handlePushProgress(data);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If stream ended without complete/error status
|
||||
// If job ended without an explicit complete/error event
|
||||
if (status === 'pushing') {
|
||||
status = 'complete';
|
||||
statusMessage = 'Image pushed successfully!';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Loader2, AlertCircle, Terminal, Sun, Moon, ShieldCheck, ShieldAlert, ShieldX, Shield } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import ScanResultsView from '../../routes/images/ScanResultsView.svelte';
|
||||
|
||||
export interface ScanResult {
|
||||
@@ -148,31 +149,10 @@
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handleScanProgress(data);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const { jobId } = await response.json();
|
||||
await watchJob(jobId, (line) => {
|
||||
handleScanProgress(line.data as any);
|
||||
});
|
||||
|
||||
// If stream ended without complete status
|
||||
if (status === 'scanning') {
|
||||
|
||||
@@ -57,6 +57,10 @@ class SidebarState {
|
||||
? (this.openMobile = !this.openMobile)
|
||||
: this.setOpen(!this.open);
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.#isMobile.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
const SYMBOL_KEY = "scn-sidebar";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const containerColumns: ColumnConfig[] = [
|
||||
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
|
||||
{ id: 'ports', label: 'Ports', width: 120, minWidth: 60 },
|
||||
{ id: 'ports', label: 'Ports', sortable: true, sortField: 'ports', width: 120, minWidth: 60 },
|
||||
{ id: 'autoUpdate', label: 'Auto-update', width: 95, minWidth: 70 },
|
||||
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 100, minWidth: 60 },
|
||||
{ id: 'actions', label: '', fixed: 'end', width: 200, minWidth: 150, resizable: true }
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.19",
|
||||
"comingSoon": true,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Inline logs panel on stacks page — view container logs without leaving the page" },
|
||||
{ "type": "feature", "text": "Make ports column sortable in containers grid" },
|
||||
{ "type": "feature", "text": "Structured auth logging with client IP (login/logout/MFA/OIDC events)" },
|
||||
{ "type": "fix", "text": "Fix memory leak: TLS context accumulation for HTTPS environments (Bun)" },
|
||||
{ "type": "fix", "text": "Fix security scanning on Docker with custom logging drivers (Loki, Fluentd, etc.)" },
|
||||
{ "type": "fix", "text": "Fix grouped log viewer not auto-scrolling on new entries" },
|
||||
{ "type": "fix", "text": "Fix container recreation error messages not surfacing actual Docker errors" },
|
||||
{ "type": "fix", "text": "Fix LDAP group-to-role mapping" },
|
||||
{ "type": "fix", "text": "Fix container file browser hiding old files" },
|
||||
{ "type": "fix", "text": "Fix SSH key permission issues on NAS filesystems" },
|
||||
{ "type": "fix", "text": "Fix binary file corruption when syncing stacks to Hawser agents" },
|
||||
{ "type": "fix", "text": "Fix UI timeout issues for long running operations" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.19"
|
||||
},
|
||||
{
|
||||
"version": "1.0.18",
|
||||
"date": "2026-02-16",
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/autocomplete"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/commands",
|
||||
"version": "6.10.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/commands"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/commands",
|
||||
"version": "6.10.1",
|
||||
@@ -215,12 +221,24 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/paulmillr/noble-hashes"
|
||||
},
|
||||
{
|
||||
"name": "@phc/format",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/simonepri/phc-format"
|
||||
},
|
||||
{
|
||||
"name": "@sveltejs/acorn-typescript",
|
||||
"version": "1.0.8",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/acorn-typescript"
|
||||
},
|
||||
{
|
||||
"name": "@types/better-sqlite3",
|
||||
"version": "7.6.13",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
|
||||
},
|
||||
{
|
||||
"name": "@types/estree",
|
||||
"version": "1.0.8",
|
||||
@@ -233,6 +251,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
|
||||
},
|
||||
{
|
||||
"name": "@types/trusted-types",
|
||||
"version": "2.0.7",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
|
||||
},
|
||||
{
|
||||
"name": "acorn",
|
||||
"version": "8.15.0",
|
||||
@@ -251,6 +275,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/ansi-styles"
|
||||
},
|
||||
{
|
||||
"name": "argon2",
|
||||
"version": "0.41.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/ranisalt/node-argon2"
|
||||
},
|
||||
{
|
||||
"name": "argparse",
|
||||
"version": "2.0.1",
|
||||
@@ -269,6 +299,36 @@
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/axobject-query"
|
||||
},
|
||||
{
|
||||
"name": "base64-js",
|
||||
"version": "1.5.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/beatgammit/base64-js"
|
||||
},
|
||||
{
|
||||
"name": "better-sqlite3",
|
||||
"version": "11.10.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/WiseLibs/better-sqlite3"
|
||||
},
|
||||
{
|
||||
"name": "bindings",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/node-bindings"
|
||||
},
|
||||
{
|
||||
"name": "bl",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/rvagg/bl"
|
||||
},
|
||||
{
|
||||
"name": "buffer",
|
||||
"version": "5.7.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/buffer"
|
||||
},
|
||||
{
|
||||
"name": "bun-types",
|
||||
"version": "1.3.6",
|
||||
@@ -281,6 +341,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/camelcase"
|
||||
},
|
||||
{
|
||||
"name": "chownr",
|
||||
"version": "1.1.4",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/chownr"
|
||||
},
|
||||
{
|
||||
"name": "cliui",
|
||||
"version": "6.0.0",
|
||||
@@ -335,9 +401,27 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/decamelize"
|
||||
},
|
||||
{
|
||||
"name": "decompress-response",
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/decompress-response"
|
||||
},
|
||||
{
|
||||
"name": "deep-extend",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/unclechu/node-deep-extend"
|
||||
},
|
||||
{
|
||||
"name": "detect-libc",
|
||||
"version": "2.1.2",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/lovell/detect-libc"
|
||||
},
|
||||
{
|
||||
"name": "devalue",
|
||||
"version": "5.6.2",
|
||||
"version": "5.6.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
@@ -349,7 +433,7 @@
|
||||
},
|
||||
{
|
||||
"name": "dockhand",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.18",
|
||||
"license": "UNLICENSED",
|
||||
"repository": null
|
||||
},
|
||||
@@ -365,6 +449,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mathiasbynens/emoji-regex"
|
||||
},
|
||||
{
|
||||
"name": "end-of-stream",
|
||||
"version": "1.4.5",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/end-of-stream"
|
||||
},
|
||||
{
|
||||
"name": "esm-env",
|
||||
"version": "1.2.2",
|
||||
@@ -373,16 +463,34 @@
|
||||
},
|
||||
{
|
||||
"name": "esrap",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/esrap"
|
||||
},
|
||||
{
|
||||
"name": "expand-template",
|
||||
"version": "2.0.3",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"repository": "https://github.com/ralphtheninja/expand-template"
|
||||
},
|
||||
{
|
||||
"name": "file-uri-to-path",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/file-uri-to-path"
|
||||
},
|
||||
{
|
||||
"name": "find-up",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/find-up"
|
||||
},
|
||||
{
|
||||
"name": "fs-constants",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/fs-constants"
|
||||
},
|
||||
{
|
||||
"name": "get-caller-file",
|
||||
"version": "2.0.5",
|
||||
@@ -390,10 +498,28 @@
|
||||
"repository": "https://github.com/stefanpenner/get-caller-file"
|
||||
},
|
||||
{
|
||||
"name": "hash-wasm",
|
||||
"version": "4.12.0",
|
||||
"name": "github-from-package",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Daninet/hash-wasm"
|
||||
"repository": "https://github.com/substack/github-from-package"
|
||||
},
|
||||
{
|
||||
"name": "ieee754",
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"repository": "https://github.com/feross/ieee754"
|
||||
},
|
||||
{
|
||||
"name": "inherits",
|
||||
"version": "2.0.4",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/inherits"
|
||||
},
|
||||
{
|
||||
"name": "ini",
|
||||
"version": "1.3.8",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/ini"
|
||||
},
|
||||
{
|
||||
"name": "is-fullwidth-code-point",
|
||||
@@ -437,12 +563,60 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Rich-Harris/magic-string"
|
||||
},
|
||||
{
|
||||
"name": "mimic-response",
|
||||
"version": "3.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/mimic-response"
|
||||
},
|
||||
{
|
||||
"name": "minimist",
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/minimistjs/minimist"
|
||||
},
|
||||
{
|
||||
"name": "mkdirp-classic",
|
||||
"version": "0.5.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/mkdirp-classic"
|
||||
},
|
||||
{
|
||||
"name": "napi-build-utils",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/inspiredware/napi-build-utils"
|
||||
},
|
||||
{
|
||||
"name": "node-abi",
|
||||
"version": "3.87.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/electron/node-abi"
|
||||
},
|
||||
{
|
||||
"name": "node-addon-api",
|
||||
"version": "8.5.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/node-addon-api"
|
||||
},
|
||||
{
|
||||
"name": "node-gyp-build",
|
||||
"version": "4.8.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/prebuild/node-gyp-build"
|
||||
},
|
||||
{
|
||||
"name": "nodemailer",
|
||||
"version": "7.0.12",
|
||||
"license": "MIT-0",
|
||||
"repository": "https://github.com/nodemailer/nodemailer"
|
||||
},
|
||||
{
|
||||
"name": "once",
|
||||
"version": "1.4.0",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/once"
|
||||
},
|
||||
{
|
||||
"name": "otpauth",
|
||||
"version": "9.4.1",
|
||||
@@ -485,6 +659,18 @@
|
||||
"license": "Unlicense",
|
||||
"repository": "https://github.com/porsager/postgres"
|
||||
},
|
||||
{
|
||||
"name": "prebuild-install",
|
||||
"version": "7.1.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/prebuild/prebuild-install"
|
||||
},
|
||||
{
|
||||
"name": "pump",
|
||||
"version": "3.0.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/pump"
|
||||
},
|
||||
{
|
||||
"name": "punycode",
|
||||
"version": "2.3.1",
|
||||
@@ -497,6 +683,18 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/soldair/node-qrcode"
|
||||
},
|
||||
{
|
||||
"name": "rc",
|
||||
"version": "1.2.8",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"repository": "https://github.com/dominictarr/rc"
|
||||
},
|
||||
{
|
||||
"name": "readable-stream",
|
||||
"version": "3.6.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/readable-stream"
|
||||
},
|
||||
{
|
||||
"name": "require-directory",
|
||||
"version": "2.1.1",
|
||||
@@ -515,12 +713,36 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/svecosystem/runed"
|
||||
},
|
||||
{
|
||||
"name": "safe-buffer",
|
||||
"version": "5.2.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/safe-buffer"
|
||||
},
|
||||
{
|
||||
"name": "semver",
|
||||
"version": "7.7.4",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/npm/node-semver"
|
||||
},
|
||||
{
|
||||
"name": "set-blocking",
|
||||
"version": "2.0.0",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/yargs/set-blocking"
|
||||
},
|
||||
{
|
||||
"name": "simple-concat",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/simple-concat"
|
||||
},
|
||||
{
|
||||
"name": "simple-get",
|
||||
"version": "4.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/simple-get"
|
||||
},
|
||||
{
|
||||
"name": "strict-event-emitter-types",
|
||||
"version": "2.0.0",
|
||||
@@ -533,12 +755,24 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/string-width"
|
||||
},
|
||||
{
|
||||
"name": "string_decoder",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/string_decoder"
|
||||
},
|
||||
{
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/strip-ansi"
|
||||
},
|
||||
{
|
||||
"name": "strip-json-comments",
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/strip-json-comments"
|
||||
},
|
||||
{
|
||||
"name": "style-mod",
|
||||
"version": "4.1.3",
|
||||
@@ -547,7 +781,7 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.46.4",
|
||||
"version": "5.53.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/svelte"
|
||||
},
|
||||
@@ -563,18 +797,42 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/wobsoriano/svelte-sonner"
|
||||
},
|
||||
{
|
||||
"name": "tar-fs",
|
||||
"version": "2.1.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/tar-fs"
|
||||
},
|
||||
{
|
||||
"name": "tar-stream",
|
||||
"version": "2.2.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/tar-stream"
|
||||
},
|
||||
{
|
||||
"name": "tr46",
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/jsdom/tr46"
|
||||
},
|
||||
{
|
||||
"name": "tunnel-agent",
|
||||
"version": "0.6.0",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/mikeal/tunnel-agent"
|
||||
},
|
||||
{
|
||||
"name": "undici-types",
|
||||
"version": "7.16.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/undici"
|
||||
},
|
||||
{
|
||||
"name": "util-deprecate",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/util-deprecate"
|
||||
},
|
||||
{
|
||||
"name": "w3c-keyname",
|
||||
"version": "2.2.8",
|
||||
@@ -605,6 +863,18 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/wrap-ansi"
|
||||
},
|
||||
{
|
||||
"name": "wrappy",
|
||||
"version": "1.0.2",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/npm/wrappy"
|
||||
},
|
||||
{
|
||||
"name": "ws",
|
||||
"version": "8.19.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/websockets/ws"
|
||||
},
|
||||
{
|
||||
"name": "y18n",
|
||||
"version": "4.0.3",
|
||||
|
||||
@@ -5,6 +5,9 @@ const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
export class IsMobile {
|
||||
#breakpoint: number;
|
||||
#current = $state(false);
|
||||
#handleResize: (() => void) | null = null;
|
||||
#handleMediaChange: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
#mql: MediaQueryList | null = null;
|
||||
|
||||
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
|
||||
this.#breakpoint = breakpoint;
|
||||
@@ -14,22 +17,31 @@ export class IsMobile {
|
||||
this.#current = window.innerWidth < this.#breakpoint;
|
||||
|
||||
// Listen for resize events
|
||||
const handleResize = () => {
|
||||
this.#handleResize = () => {
|
||||
this.#current = window.innerWidth < this.#breakpoint;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('resize', this.#handleResize);
|
||||
|
||||
// Also use matchMedia for more reliable detection
|
||||
const mql = window.matchMedia(`(max-width: ${this.#breakpoint - 1}px)`);
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
this.#mql = window.matchMedia(`(max-width: ${this.#breakpoint - 1}px)`);
|
||||
this.#handleMediaChange = (e: MediaQueryListEvent) => {
|
||||
this.#current = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handleMediaChange);
|
||||
this.#mql.addEventListener('change', this.#handleMediaChange);
|
||||
}
|
||||
}
|
||||
|
||||
get current() {
|
||||
return this.#current;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.#handleResize) {
|
||||
window.removeEventListener('resize', this.#handleResize);
|
||||
}
|
||||
if (this.#mql && this.#handleMediaChange) {
|
||||
this.#mql.removeEventListener('change', this.#handleMediaChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+99
-62
@@ -2,16 +2,16 @@
|
||||
* Core Authentication Module
|
||||
*
|
||||
* Security features:
|
||||
* - Argon2id password hashing via Bun.password (memory-hard, timing-attack resistant)
|
||||
* - Argon2id password hashing via argon2 (memory-hard, timing-attack resistant)
|
||||
* - Cryptographically secure 32-byte random session tokens
|
||||
* - HttpOnly cookies (prevents XSS from reading tokens)
|
||||
* - Secure flag (HTTPS only in production)
|
||||
* - Secure flag (protocol-aware: x-forwarded-proto or COOKIE_SECURE env var, default off)
|
||||
* - SameSite=Strict (CSRF protection)
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import { secureRandomBytes, usingFallback } from './crypto-fallback';
|
||||
import { argon2id, argon2Verify } from 'hash-wasm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import {
|
||||
getAuthSettings,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from './db';
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -98,42 +99,25 @@ export interface LoginResult {
|
||||
// Password Hashing (Argon2id)
|
||||
// ============================================
|
||||
|
||||
// Argon2id parameters (matching Bun.password defaults)
|
||||
// Argon2id parameters
|
||||
const ARGON2_MEMORY_COST = 65536; // 64 MB in kibibytes
|
||||
const ARGON2_TIME_COST = 3; // 3 iterations
|
||||
const ARGON2_PARALLELISM = 1; // Single-threaded
|
||||
const ARGON2_HASH_LENGTH = 32; // 256-bit output
|
||||
const ARGON2_SALT_LENGTH = 16; // 128-bit salt
|
||||
|
||||
/**
|
||||
* Hash a password using Argon2id
|
||||
*
|
||||
* On modern kernels (>=3.17): Uses Bun's native password API (faster)
|
||||
* On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency)
|
||||
*
|
||||
* Argon2id is the recommended variant - resistant to both side-channel and GPU attacks
|
||||
* Uses the argon2 npm package (C binding) for native performance.
|
||||
* Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// On old kernels, Bun.password.hash() crashes because it internally uses getrandom()
|
||||
// Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall
|
||||
if (usingFallback()) {
|
||||
const salt = secureRandomBytes(ARGON2_SALT_LENGTH);
|
||||
return argon2id({
|
||||
password,
|
||||
salt,
|
||||
iterations: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
memorySize: ARGON2_MEMORY_COST,
|
||||
hashLength: ARGON2_HASH_LENGTH,
|
||||
outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
|
||||
});
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation (faster)
|
||||
return Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: ARGON2_MEMORY_COST,
|
||||
timeCost: ARGON2_TIME_COST
|
||||
timeCost: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
hashLength: ARGON2_HASH_LENGTH
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,18 +125,13 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
* Verify a password against a hash
|
||||
* Uses constant-time comparison internally
|
||||
*
|
||||
* Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible
|
||||
* The argon2 npm package uses standard PHC format, compatible with existing hashes
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
// On old kernels, use hash-wasm for verification
|
||||
if (usingFallback()) {
|
||||
return await argon2Verify({ password, hash });
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch (e) {
|
||||
console.error('[Auth] argon2.verify() threw unexpectedly:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -169,14 +148,43 @@ function generateSessionToken(): string {
|
||||
return secureRandomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to set the Secure flag on the session cookie.
|
||||
*
|
||||
* Priority:
|
||||
* 1. COOKIE_SECURE env var ('true' / 'false') — explicit override
|
||||
* 2. x-forwarded-proto header — set by Traefik / Nginx / Caddy
|
||||
* 3. false — default, matches v1.0.18 Bun behavior
|
||||
*
|
||||
* Defaulting to false (not NODE_ENV) is intentional: Dockhand is commonly
|
||||
* run over plain HTTP in homelabs. Setting Secure unconditionally in production
|
||||
* causes a login loop when there is no HTTPS reverse proxy, because browsers
|
||||
* silently discard Secure cookies on HTTP connections.
|
||||
*
|
||||
* Users behind an HTTPS reverse proxy get Secure cookies automatically via
|
||||
* x-forwarded-proto. Users who terminate TLS in the app itself can opt in
|
||||
* with COOKIE_SECURE=true.
|
||||
*/
|
||||
function isSecureContext(request?: Request): boolean {
|
||||
if (process.env.COOKIE_SECURE === 'false') return false;
|
||||
if (process.env.COOKIE_SECURE === 'true') return true;
|
||||
if (request) {
|
||||
const proto = request.headers.get('x-forwarded-proto');
|
||||
if (proto === 'https') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
* @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc.
|
||||
* @param request - Optional incoming request (used to detect HTTPS via x-forwarded-proto)
|
||||
*/
|
||||
export async function createUserSession(
|
||||
userId: number,
|
||||
provider: string,
|
||||
cookies: Cookies
|
||||
cookies: Cookies,
|
||||
request?: Request
|
||||
): Promise<Session> {
|
||||
// Clean up expired sessions periodically
|
||||
await deleteExpiredSessions();
|
||||
@@ -198,7 +206,7 @@ export async function createUserSession(
|
||||
const session = await dbCreateSession(sessionId, userId, provider, expiresAt);
|
||||
|
||||
// Set secure cookie
|
||||
setSessionCookie(cookies, sessionId, sessionTimeout);
|
||||
setSessionCookie(cookies, sessionId, sessionTimeout, request);
|
||||
|
||||
// Update user's last login time
|
||||
await updateUser(userId, { lastLogin: new Date().toISOString() });
|
||||
@@ -209,11 +217,11 @@ export async function createUserSession(
|
||||
/**
|
||||
* Set the session cookie with secure attributes
|
||||
*/
|
||||
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number): void {
|
||||
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, request?: Request): void {
|
||||
cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
||||
sameSite: 'strict', // CSRF protection
|
||||
maxAge: maxAge // Session timeout in seconds
|
||||
});
|
||||
@@ -563,6 +571,19 @@ export async function authenticateLdap(
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in an LDAP filter value (RFC 4515).
|
||||
* Prevents LDAP injection via wildcards or control characters.
|
||||
*/
|
||||
function escapeLdapFilterValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\0/g, '\\00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Try authentication against a specific LDAP configuration
|
||||
*/
|
||||
@@ -585,8 +606,10 @@ async function tryLdapAuth(
|
||||
await client.bind(config.bindDn, config.bindPassword);
|
||||
}
|
||||
|
||||
// Search for the user
|
||||
const filter = config.userFilter.replace('{{username}}', username);
|
||||
// Escape the username before interpolating into the LDAP filter (RFC 4515)
|
||||
// to prevent LDAP injection via wildcards or special characters.
|
||||
const safeUsername = escapeLdapFilterValue(username);
|
||||
const filter = config.userFilter.replace('{{username}}', safeUsername);
|
||||
const { searchEntries } = await client.search(config.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: filter,
|
||||
@@ -599,9 +622,11 @@ async function tryLdapAuth(
|
||||
]
|
||||
});
|
||||
|
||||
// Use a single generic error for both "not found" and "wrong password"
|
||||
// to avoid leaking whether a username exists via response content or timing.
|
||||
if (searchEntries.length === 0) {
|
||||
await client.unbind();
|
||||
return { success: false, error: 'User not found' };
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
const userEntry = searchEntries[0];
|
||||
@@ -666,11 +691,11 @@ async function tryLdapAuth(
|
||||
if (adminRole) {
|
||||
const hasAdminRole = await userHasAdminRole(user.id);
|
||||
if (shouldBeAdmin && !hasAdminRole) {
|
||||
// Assign Admin role
|
||||
await assignUserRole(user.id, adminRole.id, null);
|
||||
} else if (!shouldBeAdmin && hasAdminRole && config.adminGroup) {
|
||||
// Remove Admin role if user is no longer in admin group
|
||||
await removeUserRole(user.id, adminRole.id);
|
||||
}
|
||||
// Note: We don't remove Admin role if not in LDAP group anymore
|
||||
// to prevent accidental lockouts (same behavior as before)
|
||||
}
|
||||
|
||||
// Process role mappings (Enterprise feature)
|
||||
@@ -683,14 +708,29 @@ async function tryLdapAuth(
|
||||
const userExistingRoles = await getUserRoles(user.id);
|
||||
const existingRoleIds = new Set(userExistingRoles.map(r => r.roleId));
|
||||
|
||||
for (const mapping of roleMappings) {
|
||||
// Skip if user already has this role
|
||||
if (existingRoleIds.has(mapping.roleId)) continue;
|
||||
// All role IDs referenced in mappings (these are LDAP-managed)
|
||||
const mappedRoleIds = new Set(roleMappings.map(m => m.roleId));
|
||||
|
||||
// Check if user is a member of the LDAP group
|
||||
// Determine which mapped roles user should have based on current group membership
|
||||
const shouldHaveRoleIds = new Set<number>();
|
||||
for (const mapping of roleMappings) {
|
||||
const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn);
|
||||
if (isInGroup) {
|
||||
await assignUserRole(user.id, mapping.roleId, undefined);
|
||||
shouldHaveRoleIds.add(mapping.roleId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add roles user should have but doesn't
|
||||
for (const roleId of shouldHaveRoleIds) {
|
||||
if (!existingRoleIds.has(roleId)) {
|
||||
await assignUserRole(user.id, roleId, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove mapped roles user has but shouldn't
|
||||
for (const roleId of mappedRoleIds) {
|
||||
if (existingRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) {
|
||||
await removeUserRole(user.id, roleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -744,8 +784,9 @@ async function checkLdapGroupMembership(
|
||||
let groupFilter: string;
|
||||
|
||||
if (config.groupFilter) {
|
||||
// User provided custom filter
|
||||
searchBase = config.groupBaseDn || groupDnOrName;
|
||||
// User provided custom filter - use group DN directly when it's a full DN
|
||||
// to avoid searching all groups under groupBaseDn
|
||||
searchBase = isFullDn ? groupDnOrName : (config.groupBaseDn || groupDnOrName);
|
||||
groupFilter = config.groupFilter
|
||||
.replace('{{username}}', userDn)
|
||||
.replace('{{user_dn}}', userDn)
|
||||
@@ -765,7 +806,7 @@ async function checkLdapGroupMembership(
|
||||
}
|
||||
|
||||
const { searchEntries } = await client.search(searchBase, {
|
||||
scope: isFullDn && !config.groupFilter ? 'base' : 'sub',
|
||||
scope: isFullDn ? 'base' : 'sub',
|
||||
filter: groupFilter,
|
||||
sizeLimit: 1
|
||||
});
|
||||
@@ -825,9 +866,7 @@ function generateBackupCodes(): string[] {
|
||||
async function hashBackupCode(code: string): Promise<string> {
|
||||
// Normalize: uppercase, remove spaces and dashes
|
||||
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(normalized);
|
||||
return hasher.digest('hex');
|
||||
return createHash('sha256').update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1172,9 +1211,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
|
||||
*/
|
||||
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
||||
const codeVerifier = secureRandomBytes(32).toString('base64url');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(codeVerifier);
|
||||
const codeChallenge = hasher.digest('base64url') as string;
|
||||
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Crypto Fallback for Old Linux Kernels
|
||||
*
|
||||
* The getrandom() syscall was added in Linux 3.17. On older kernels (like 3.10.x),
|
||||
* Bun's built-in crypto functions will fail with "getrandom() failed to provide entropy".
|
||||
* Node.js crypto functions may fail with "getrandom() failed to provide entropy".
|
||||
*
|
||||
* This module provides fallback implementations that read from /dev/urandom directly
|
||||
* when running on kernels older than 3.17.
|
||||
|
||||
+34
-40
@@ -7,7 +7,6 @@
|
||||
|
||||
import {
|
||||
db,
|
||||
rawClient,
|
||||
isPostgres,
|
||||
isSqlite,
|
||||
eq,
|
||||
@@ -200,6 +199,10 @@ export async function deleteEnvironment(id: number): Promise<boolean> {
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) return false;
|
||||
|
||||
// Clean up in-memory metrics
|
||||
const { clearEnvironmentMetrics } = await import('./metrics-store.js');
|
||||
clearEnvironmentMetrics(id);
|
||||
|
||||
// Clean up related records that don't have cascade delete defined
|
||||
try {
|
||||
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
|
||||
@@ -576,45 +579,28 @@ export async function saveHostMetric(
|
||||
memoryPercent: number,
|
||||
memoryUsed: number,
|
||||
memoryTotal: number,
|
||||
environmentId?: number
|
||||
environmentId?: number,
|
||||
_skipEnvCheck = false
|
||||
): Promise<void> {
|
||||
// Verify environment exists before inserting (avoids FK violations on deleted envs)
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) return;
|
||||
}
|
||||
|
||||
await db.insert(hostMetrics).values({
|
||||
environmentId: environmentId || null,
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
memoryUsed,
|
||||
memoryTotal
|
||||
});
|
||||
|
||||
// Cleanup old metrics (keep last 24 hours)
|
||||
const cutoff24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(hostMetrics).where(sql`timestamp < ${cutoff24h}`);
|
||||
// Delegated to in-memory ring buffer (no DB writes)
|
||||
if (!environmentId) return;
|
||||
const { pushMetric } = await import('./metrics-store.js');
|
||||
pushMetric(environmentId, cpuPercent, memoryPercent, memoryUsed, memoryTotal);
|
||||
}
|
||||
|
||||
export async function getHostMetrics(limit = 60, environmentId?: number): Promise<HostMetric[]> {
|
||||
if (environmentId) {
|
||||
return db.select().from(hostMetrics)
|
||||
.where(eq(hostMetrics.environmentId, environmentId))
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(limit);
|
||||
const { getMetricsHistory } = await import('./metrics-store.js');
|
||||
// getMetricsHistory returns oldest-first, but callers expect newest-first
|
||||
return getMetricsHistory(environmentId, limit).reverse();
|
||||
}
|
||||
return db.select().from(hostMetrics)
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(limit);
|
||||
const { getAllMetrics } = await import('./metrics-store.js');
|
||||
return getAllMetrics(limit);
|
||||
}
|
||||
|
||||
export async function getLatestHostMetrics(environmentId: number): Promise<HostMetric | null> {
|
||||
const results = await db.select().from(hostMetrics)
|
||||
.where(eq(hostMetrics.environmentId, environmentId))
|
||||
.orderBy(desc(hostMetrics.timestamp))
|
||||
.limit(1);
|
||||
return results[0] ?? null;
|
||||
const { getLatestMetric } = await import('./metrics-store.js');
|
||||
return getLatestMetric(environmentId);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -3269,20 +3255,23 @@ export interface ContainerEventResult {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
|
||||
// Timestamp is already an ISO-8601 string from event-subprocess
|
||||
// Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly
|
||||
const result = await db.insert(containerEvents).values({
|
||||
export async function logContainerEvent(
|
||||
data: ContainerEventCreateData
|
||||
): Promise<ContainerEventData> {
|
||||
const attrs = data.actorAttributes ? JSON.stringify(data.actorAttributes) : null;
|
||||
|
||||
const [inserted] = await db.insert(containerEvents).values({
|
||||
environmentId: data.environmentId ?? null,
|
||||
containerId: data.containerId,
|
||||
containerName: data.containerName ?? null,
|
||||
image: data.image ?? null,
|
||||
action: data.action,
|
||||
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
|
||||
actorAttributes: attrs,
|
||||
timestamp: data.timestamp
|
||||
}).returning();
|
||||
}).returning({ id: containerEvents.id });
|
||||
|
||||
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
|
||||
const event = await getContainerEvent(inserted.id);
|
||||
return event!;
|
||||
}
|
||||
|
||||
export async function getContainerEvent(id: number): Promise<ContainerEventData | undefined> {
|
||||
@@ -4502,11 +4491,16 @@ export async function setStackEnvVars(
|
||||
));
|
||||
}
|
||||
|
||||
// Insert new vars
|
||||
// Insert new vars (deduplicate by key - last entry wins)
|
||||
if (variables.length > 0) {
|
||||
const seen = new Map<string, { key: string; value: string; isSecret?: boolean }>();
|
||||
for (const v of variables) {
|
||||
seen.set(v.key, v);
|
||||
}
|
||||
const deduped = Array.from(seen.values());
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(stackEnvironmentVariables).values(
|
||||
variables.map(v => ({
|
||||
deduped.map(v => ({
|
||||
stackName,
|
||||
environmentId,
|
||||
key: v.key,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Database Connection Module
|
||||
*
|
||||
* Provides a unified database connection using Bun's SQL API.
|
||||
* Supports both SQLite (default) and PostgreSQL (via DATABASE_URL).
|
||||
*/
|
||||
|
||||
import { SQL } from 'bun';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Database configuration
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
|
||||
// Detect database type
|
||||
export const isPostgres = databaseUrl && (databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://'));
|
||||
export const isSqlite = !isPostgres;
|
||||
|
||||
/**
|
||||
* Read a SQL file from the appropriate sql directory.
|
||||
*/
|
||||
function readSql(filename: string): string {
|
||||
const sqlDir = isPostgres ? 'postgres' : 'sqlite';
|
||||
return readFileSync(join(__dirname, sqlDir, 'sql', filename), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PostgreSQL connection URL format.
|
||||
*/
|
||||
function validatePostgresUrl(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
|
||||
exitWithError(`Invalid protocol "${parsed.protocol}". Expected "postgres:" or "postgresql:"`, url);
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
exitWithError('Missing hostname in DATABASE_URL', url);
|
||||
}
|
||||
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
exitWithError('Missing database name in DATABASE_URL', url);
|
||||
}
|
||||
} catch {
|
||||
exitWithError('Invalid URL format', url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print connection error and exit.
|
||||
*/
|
||||
function exitWithError(error: string, url?: string): never {
|
||||
console.error('\n' + '='.repeat(70));
|
||||
console.error('DATABASE CONNECTION ERROR');
|
||||
console.error('='.repeat(70));
|
||||
console.error(`\nError: ${error}`);
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.password) parsed.password = '***';
|
||||
console.error(`\nProvided URL: ${parsed.toString()}`);
|
||||
} catch {
|
||||
console.error(`\nProvided URL: ${url.replace(/:[^:@]+@/, ':***@')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('\n' + '-'.repeat(70));
|
||||
console.error('DATABASE_URL format:');
|
||||
console.error('-'.repeat(70));
|
||||
console.error('\n postgres://USER:PASSWORD@HOST:PORT/DATABASE');
|
||||
console.error('\nExamples:');
|
||||
console.error(' postgres://dockhand:secret@localhost:5432/dockhand');
|
||||
console.error(' postgres://admin:p4ssw0rd@192.168.1.100:5432/dockhand');
|
||||
console.error(' postgresql://user:pass@db.example.com/mydb?sslmode=require');
|
||||
console.error('\n' + '-'.repeat(70));
|
||||
console.error('To use SQLite instead, remove the DATABASE_URL environment variable.');
|
||||
console.error('='.repeat(70) + '\n');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the database connection.
|
||||
*/
|
||||
function createConnection(): SQL {
|
||||
if (isPostgres) {
|
||||
// Validate PostgreSQL URL
|
||||
validatePostgresUrl(databaseUrl!);
|
||||
|
||||
console.log('Connecting to PostgreSQL database...');
|
||||
try {
|
||||
const sql = new SQL(databaseUrl!);
|
||||
return sql;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
exitWithError(`Failed to connect to PostgreSQL: ${message}`, databaseUrl);
|
||||
}
|
||||
} else {
|
||||
// SQLite: Ensure db directory exists
|
||||
const dbDir = join(dataDir, 'db');
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = join(dbDir, 'dockhand.db');
|
||||
console.log(`Using SQLite database at: ${dbPath}`);
|
||||
|
||||
const sql = new SQL(`sqlite://${dbPath}`);
|
||||
|
||||
// Enable WAL mode for better performance
|
||||
sql.run('PRAGMA journal_mode = WAL');
|
||||
|
||||
return sql;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database schema.
|
||||
*/
|
||||
async function initializeSchema(sql: SQL): Promise<void> {
|
||||
try {
|
||||
// Create schema (tables)
|
||||
await sql.run(readSql('schema.sql'));
|
||||
|
||||
// Create indexes
|
||||
await sql.run(readSql('indexes.sql'));
|
||||
|
||||
// Insert seed data
|
||||
await sql.run(readSql('seed.sql'));
|
||||
|
||||
// Update system roles
|
||||
await sql.run(readSql('system-roles.sql'));
|
||||
|
||||
// Run maintenance
|
||||
await sql.run(readSql('maintenance.sql'));
|
||||
|
||||
console.log(`Database initialized successfully (${isPostgres ? 'PostgreSQL' : 'SQLite'})`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to initialize database schema:', message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the database connection
|
||||
export const sql = createConnection();
|
||||
|
||||
// Initialize schema (runs async but we handle it)
|
||||
initializeSchema(sql).catch((error) => {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DB] Database initialization failed:', errorMsg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to convert SQLite integer booleans to JS booleans.
|
||||
* PostgreSQL returns actual booleans, SQLite returns 0/1.
|
||||
*/
|
||||
export function toBool(value: any): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert JS boolean to database value.
|
||||
* PostgreSQL uses boolean, SQLite uses 0/1.
|
||||
*/
|
||||
export function fromBool(value: boolean): boolean | number {
|
||||
return isPostgres ? value : (value ? 1 : 0);
|
||||
}
|
||||
@@ -208,11 +208,11 @@ function readMigrationJournal(migrationsFolder: string): MigrationJournal | null
|
||||
async function getAppliedMigrations(client: any, postgres: boolean): Promise<AppliedMigration[]> {
|
||||
try {
|
||||
if (postgres) {
|
||||
// PostgreSQL using Bun.sql - note the 'drizzle' schema
|
||||
// PostgreSQL using postgres-js - note the 'drizzle' schema
|
||||
const result = await client`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`;
|
||||
return result.map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
|
||||
} else {
|
||||
// SQLite using bun:sqlite
|
||||
// SQLite using better-sqlite3
|
||||
const stmt = client.prepare('SELECT hash, created_at FROM __drizzle_migrations ORDER BY id');
|
||||
return stmt.all().map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
|
||||
}
|
||||
@@ -484,10 +484,10 @@ async function runMigrations(
|
||||
// Run migrations
|
||||
try {
|
||||
if (postgres) {
|
||||
const { migrate } = await import('drizzle-orm/bun-sql/migrator');
|
||||
const { migrate } = await import('drizzle-orm/postgres-js/migrator');
|
||||
await migrate(database, { migrationsFolder });
|
||||
} else {
|
||||
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
|
||||
const { migrate } = await import('drizzle-orm/better-sqlite3/migrator');
|
||||
await migrate(database, { migrationsFolder });
|
||||
}
|
||||
|
||||
@@ -605,7 +605,7 @@ async function initializeDatabase() {
|
||||
logHeader('DATABASE INITIALIZATION');
|
||||
|
||||
if (isPostgres) {
|
||||
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
|
||||
// PostgreSQL via postgres-js
|
||||
validatePostgresUrl(config.databaseUrl!);
|
||||
|
||||
logInfo(`Database: PostgreSQL`);
|
||||
@@ -634,7 +634,7 @@ async function initializeDatabase() {
|
||||
handleMigrationFailure(result.error, true);
|
||||
}
|
||||
} else {
|
||||
// SQLite via bun:sqlite
|
||||
// SQLite via better-sqlite3
|
||||
const dbDir = join(config.dataDir, 'db');
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
@@ -645,8 +645,8 @@ async function initializeDatabase() {
|
||||
logInfo(`Database: SQLite`);
|
||||
logInfo(`Path: ${dbPath}`);
|
||||
|
||||
const { drizzle } = await import('drizzle-orm/bun-sqlite');
|
||||
const { Database } = await import('bun:sqlite');
|
||||
const { drizzle } = await import('drizzle-orm/better-sqlite3');
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
|
||||
// Import SQLite schema
|
||||
schema = await import('./schema/index.js');
|
||||
@@ -655,11 +655,11 @@ async function initializeDatabase() {
|
||||
rawClient = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better performance and concurrency
|
||||
rawClient.exec('PRAGMA journal_mode = WAL');
|
||||
rawClient.pragma('journal_mode = WAL');
|
||||
// Synchronous NORMAL is a good balance between safety and speed
|
||||
rawClient.exec('PRAGMA synchronous = NORMAL');
|
||||
rawClient.pragma('synchronous = NORMAL');
|
||||
// Increase busy timeout to handle concurrent access (5 seconds)
|
||||
rawClient.exec('PRAGMA busy_timeout = 5000');
|
||||
rawClient.pragma('busy_timeout = 5000');
|
||||
|
||||
db = drizzle({ client: rawClient, schema });
|
||||
logSuccess('SQLite database opened');
|
||||
|
||||
+638
-157
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,25 @@
|
||||
* Container Event Emitter
|
||||
*
|
||||
* Shared EventEmitter for broadcasting container events to SSE clients.
|
||||
* Events are emitted by the subprocess-manager when it receives them from the event-subprocess.
|
||||
* Events are emitted by the collection worker when processing Docker events.
|
||||
*
|
||||
* IMPORTANT: Uses globalThis to ensure a single instance across all module imports.
|
||||
* In Vite dev mode and SvelteKit production builds, server modules can be loaded
|
||||
* multiple times (HMR, chunking), creating separate EventEmitter instances.
|
||||
* Using globalThis guarantees emitters and listeners share the same object.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Event emitter for broadcasting new events to SSE clients
|
||||
// Used by:
|
||||
// - subprocess-manager.ts: emits events received from event-subprocess via IPC
|
||||
// - api/activity/events/+server.ts: listens for events to broadcast via SSE
|
||||
export const containerEventEmitter = new EventEmitter();
|
||||
const GLOBAL_KEY = '__dockhand_container_event_emitter__';
|
||||
|
||||
// Allow up to 100 concurrent SSE listeners (default is 10)
|
||||
// This prevents MaxListenersExceededWarning with many dashboard clients
|
||||
containerEventEmitter.setMaxListeners(100);
|
||||
// Ensure single instance via globalThis
|
||||
if (!(globalThis as any)[GLOBAL_KEY]) {
|
||||
const emitter = new EventEmitter();
|
||||
// Allow up to 100 concurrent SSE listeners (default is 10)
|
||||
// This prevents MaxListenersExceededWarning with many dashboard clients
|
||||
emitter.setMaxListeners(100);
|
||||
(globalThis as any)[GLOBAL_KEY] = emitter;
|
||||
}
|
||||
|
||||
export const containerEventEmitter: EventEmitter = (globalThis as any)[GLOBAL_KEY];
|
||||
|
||||
+67
-50
@@ -1,5 +1,7 @@
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, relative } from 'node:path';
|
||||
import { spawn as nodeSpawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
getGitRepository,
|
||||
getGitCredential,
|
||||
@@ -14,6 +16,26 @@ import {
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
|
||||
/**
|
||||
* Collect stdout, stderr and exit code from a spawned process.
|
||||
*/
|
||||
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
stderr: Buffer.concat(stderrChunks).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Directory for storing cloned repositories
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos'));
|
||||
@@ -79,7 +101,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
if (uid === undefined || uid === 0) return; // root or not available
|
||||
|
||||
try {
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const passwd = readFileSync('/etc/passwd', 'utf-8');
|
||||
const uidStr = `:${uid}:`;
|
||||
if (passwd.split('\n').some(line => {
|
||||
const parts = line.split(':');
|
||||
@@ -100,17 +122,17 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
// Create temp passwd/group with the missing entry
|
||||
try {
|
||||
const gid = process.getgid?.() ?? uid;
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const group = await Bun.file('/etc/group').text();
|
||||
const passwd = readFileSync('/etc/passwd', 'utf-8');
|
||||
const group = readFileSync('/etc/group', 'utf-8');
|
||||
|
||||
const passwdEntry = `dockhand:x:${uid}:${gid}:Dockhand:/home/dockhand:/bin/sh`;
|
||||
await Bun.write(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');
|
||||
writeFileSync(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');
|
||||
|
||||
const gidExists = group.split('\n').some(line => line.split(':')[2] === String(gid));
|
||||
if (gidExists) {
|
||||
await Bun.write(TMP_GROUP, group);
|
||||
writeFileSync(TMP_GROUP, group);
|
||||
} else {
|
||||
await Bun.write(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
|
||||
writeFileSync(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
|
||||
}
|
||||
|
||||
_nssWrapperNeeded = true;
|
||||
@@ -135,8 +157,10 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
await ensurePasswdEntry(env);
|
||||
|
||||
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
|
||||
// Create a temporary SSH key file (use absolute path so SSH can find it)
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
|
||||
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
|
||||
// SSH refuses keys that are accessible by others. /tmp is always a proper filesystem.
|
||||
const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;
|
||||
|
||||
// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
|
||||
let keyContent = credential.sshPrivateKey;
|
||||
@@ -144,18 +168,19 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
keyContent += '\n';
|
||||
}
|
||||
|
||||
await Bun.write(sshKeyPath, keyContent);
|
||||
writeFileSync(sshKeyPath, keyContent);
|
||||
// Ensure SSH key has correct permissions (0600 = owner read/write only)
|
||||
// Bun.write's mode option doesn't always work reliably, so use chmodSync
|
||||
// writeFileSync's mode option doesn't always work reliably, so use chmodSync
|
||||
chmodSync(sshKeyPath, 0o600);
|
||||
|
||||
// If key has a passphrase, decrypt it in-place so SSH can use it non-interactively
|
||||
if (credential.sshPassphrase) {
|
||||
const result = Bun.spawnSync([
|
||||
'ssh-keygen', '-p', '-f', sshKeyPath,
|
||||
'-P', credential.sshPassphrase, '-N', ''
|
||||
], { env, stderr: 'pipe' });
|
||||
if (result.exitCode !== 0) {
|
||||
const result = spawnSync(
|
||||
'ssh-keygen',
|
||||
['-p', '-f', sshKeyPath, '-P', credential.sshPassphrase, '-N', ''],
|
||||
{ env, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr.toString().trim();
|
||||
console.warn(`[git] Failed to decrypt SSH key: ${stderr}`);
|
||||
}
|
||||
@@ -173,7 +198,7 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
|
||||
function cleanupSshKey(credential: GitCredential | null): void {
|
||||
if (credential?.authType === 'ssh') {
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;
|
||||
try {
|
||||
if (existsSync(sshKeyPath)) {
|
||||
rmSync(sshKeyPath);
|
||||
@@ -207,21 +232,15 @@ function buildRepoUrl(url: string, credential: GitCredential | null): string {
|
||||
|
||||
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
try {
|
||||
const proc = Bun.spawn(['git', ...args], {
|
||||
const proc = nodeSpawn('git', args, {
|
||||
cwd,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text()
|
||||
]);
|
||||
const result = await collectProcess(proc);
|
||||
|
||||
const code = await proc.exited;
|
||||
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), code };
|
||||
return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode };
|
||||
} catch (err: any) {
|
||||
return { stdout: '', stderr: err.message, code: 1 };
|
||||
}
|
||||
@@ -350,8 +369,6 @@ async function testRepositoryConnection(options: {
|
||||
env
|
||||
);
|
||||
|
||||
cleanupSshKey(credential);
|
||||
|
||||
if (result.code !== 0) {
|
||||
console.error('[Git] Connection test failed:', result.stderr);
|
||||
return { success: false, error: cleanGitError(result.stderr) };
|
||||
@@ -366,7 +383,6 @@ async function testRepositoryConnection(options: {
|
||||
process.cwd(),
|
||||
env
|
||||
);
|
||||
cleanupSshKey(credential);
|
||||
|
||||
if (allBranchesResult.code !== 0) {
|
||||
return { success: false, error: cleanGitError(allBranchesResult.stderr) };
|
||||
@@ -400,8 +416,9 @@ async function testRepositoryConnection(options: {
|
||||
lastCommit
|
||||
};
|
||||
} catch (error: any) {
|
||||
cleanupSshKey(credential);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
cleanupSshKey(credential);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +536,7 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
|
||||
throw new Error(`Compose file not found: ${repo.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
|
||||
// Update repository status
|
||||
await updateGitRepository(repoId, {
|
||||
@@ -798,7 +815,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
throw new Error(`Compose file not found: ${gitStack.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Compose content:`);
|
||||
console.log(composeContent);
|
||||
@@ -819,7 +836,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
console.log(`${logPrefix} Reading env file...`);
|
||||
envFileContent = await Bun.file(envFilePath).text();
|
||||
envFileContent = readFileSync(envFilePath, 'utf-8');
|
||||
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
|
||||
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
|
||||
|
||||
@@ -1142,7 +1159,7 @@ export async function deployGitStackWithProgress(
|
||||
throw new Error(`Compose file not found: ${gitStack.composePath}`);
|
||||
}
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
|
||||
// Determine the compose directory (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
@@ -1153,7 +1170,7 @@ export async function deployGitStackWithProgress(
|
||||
const envFilePath = join(repoPath, gitStack.envFilePath);
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
const envContent = await Bun.file(envFilePath).text();
|
||||
const envContent = readFileSync(envFilePath, 'utf-8');
|
||||
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
|
||||
} catch (err) {
|
||||
// Log but don't fail - env file is optional
|
||||
@@ -1251,12 +1268,11 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st
|
||||
const maxDepth = 3;
|
||||
|
||||
// Use find to locate all .env* files
|
||||
const proc = Bun.spawn(['find', repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
const proc = nodeSpawn('find', [repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const findResult = await collectProcess(proc);
|
||||
const output = findResult.stdout;
|
||||
|
||||
const files = output.trim().split('\n').filter(f => f);
|
||||
const envFiles: string[] = [];
|
||||
@@ -1372,7 +1388,7 @@ export async function readGitStackEnvFile(
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(fullPath).text();
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
const vars = parseEnvFileContent(content);
|
||||
return { vars };
|
||||
} catch (error: any) {
|
||||
@@ -1426,17 +1442,18 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
|
||||
|
||||
// Clone with depth 1 (shallow clone for speed)
|
||||
const cloneProc = Bun.spawn(
|
||||
['git', 'clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
|
||||
const cloneProc = nodeSpawn(
|
||||
'git',
|
||||
['clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
|
||||
{
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
const cloneStderr = await new Response(cloneProc.stderr).text();
|
||||
const cloneExitCode = await cloneProc.exited;
|
||||
const cloneResult = await collectProcess(cloneProc);
|
||||
const cloneStderr = cloneResult.stderr;
|
||||
const cloneExitCode = cloneResult.exitCode;
|
||||
|
||||
if (cloneExitCode !== 0) {
|
||||
console.error(`${logPrefix} Clone failed:`, cloneStderr);
|
||||
@@ -1455,7 +1472,7 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
// Read base .env file if it exists
|
||||
if (existsSync(baseEnvPath)) {
|
||||
console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`);
|
||||
const content = await Bun.file(baseEnvPath).text();
|
||||
const content = readFileSync(baseEnvPath, 'utf-8');
|
||||
const baseVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(baseVars)) {
|
||||
vars[key] = value;
|
||||
@@ -1471,7 +1488,7 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
|
||||
const additionalEnvPath = join(tempDir, envFilePath);
|
||||
if (existsSync(additionalEnvPath)) {
|
||||
console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
|
||||
const content = await Bun.file(additionalEnvPath).text();
|
||||
const content = readFileSync(additionalEnvPath, 'utf-8');
|
||||
const additionalVars = parseEnvFileContent(content, 'preview');
|
||||
for (const [key, value] of Object.entries(additionalVars)) {
|
||||
vars[key] = value;
|
||||
|
||||
+315
-19
@@ -5,10 +5,11 @@
|
||||
* Handles request/response correlation, heartbeat tracking, and metrics collection.
|
||||
*/
|
||||
|
||||
import { db, hawserTokens, environments, eq } from './db/drizzle.js';
|
||||
import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js';
|
||||
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
|
||||
@@ -40,7 +41,7 @@ export interface EdgeConnection {
|
||||
hostname: string;
|
||||
capabilities: string[];
|
||||
connectedAt: Date;
|
||||
lastHeartbeat: Date;
|
||||
lastHeartbeat: number;
|
||||
pendingRequests: Map<string, PendingRequest>;
|
||||
pendingStreamRequests: Map<string, PendingStreamRequest>;
|
||||
lastMetrics?: {
|
||||
@@ -76,6 +77,9 @@ declare global {
|
||||
var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined;
|
||||
var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMessage: ((ws: any, msg: any, connId: string) => Promise<void>) | undefined;
|
||||
var __hawserHandleDisconnect: ((ws: any, connId: string) => void) | undefined;
|
||||
var __terminalHandleExecMessage: ((msg: any) => void) | undefined;
|
||||
}
|
||||
export const edgeConnections: Map<number, EdgeConnection> =
|
||||
globalThis.__hawserEdgeConnections ?? (globalThis.__hawserEdgeConnections = new Map());
|
||||
@@ -94,7 +98,7 @@ export function initializeEdgeManager(): void {
|
||||
const timeout = 90 * 1000; // 90 seconds (3 missed heartbeats)
|
||||
|
||||
for (const [envId, conn] of edgeConnections) {
|
||||
if (now - conn.lastHeartbeat.getTime() > timeout) {
|
||||
if (now - conn.lastHeartbeat > timeout) {
|
||||
const pendingCount = conn.pendingRequests.size;
|
||||
const streamCount = conn.pendingStreamRequests.size;
|
||||
console.log(
|
||||
@@ -120,6 +124,21 @@ export function initializeEdgeManager(): void {
|
||||
updateEnvironmentStatus(envId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain reconnection tracker: reset for stable connections, prune stale entries
|
||||
for (const [envId, tracker] of reconnectTracker) {
|
||||
const conn = edgeConnections.get(envId);
|
||||
if (conn && now - conn.lastHeartbeat < STABLE_THRESHOLD_MS) {
|
||||
// Connection is stable — reset tracker so next reconnect is unthrottled
|
||||
reconnectTracker.delete(envId);
|
||||
} else if (!conn && tracker.timestamps.length > 0) {
|
||||
const lastAttempt = tracker.timestamps[tracker.timestamps.length - 1];
|
||||
if (now - lastAttempt > STALE_TRACKER_MS) {
|
||||
// No connection and no recent attempts — clean up
|
||||
reconnectTracker.delete(envId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
@@ -137,6 +156,7 @@ export function stopEdgeManager(): void {
|
||||
conn.ws.close(1001, 'Server shutdown');
|
||||
}
|
||||
edgeConnections.clear();
|
||||
reconnectTracker.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +209,7 @@ export async function handleEdgeContainerEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// Register global handler for patch-build.ts to use
|
||||
// Register global handler for server.js to use
|
||||
globalThis.__hawserHandleContainerEvent = handleEdgeContainerEvent;
|
||||
|
||||
/**
|
||||
@@ -218,14 +238,8 @@ export async function handleEdgeMetrics(
|
||||
? (metrics.memoryUsed / metrics.memoryTotal) * 100
|
||||
: 0;
|
||||
|
||||
// Save to database using the existing function
|
||||
await saveHostMetric(
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
metrics.memoryUsed,
|
||||
metrics.memoryTotal,
|
||||
environmentId
|
||||
);
|
||||
// Push to in-memory ring buffer
|
||||
pushMetric(environmentId, cpuPercent, memoryPercent, metrics.memoryUsed, metrics.memoryTotal);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Hawser] Error saving metrics:', errorMsg);
|
||||
@@ -288,6 +302,13 @@ export async function generateHawserToken(
|
||||
edgeConnections.delete(environmentId);
|
||||
}
|
||||
|
||||
// Revoke all existing active tokens for this environment so the old agent
|
||||
// can no longer reconnect and fight with the new one over the connection slot
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(hawserTokens.environmentId, environmentId), eq(hawserTokens.isActive, true)));
|
||||
|
||||
// Use provided token or generate a new one
|
||||
let token: string;
|
||||
if (rawToken) {
|
||||
@@ -396,6 +417,7 @@ export function handleEdgeConnection(
|
||||
// Reject all pending requests before closing
|
||||
for (const [requestId, pending] of existing.pendingRequests) {
|
||||
console.log(`[Hawser] Rejecting pending request ${requestId} due to connection replacement`);
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Connection replaced by new agent'));
|
||||
}
|
||||
for (const [requestId, pending] of existing.pendingStreamRequests) {
|
||||
@@ -405,7 +427,12 @@ export function handleEdgeConnection(
|
||||
existing.pendingRequests.clear();
|
||||
existing.pendingStreamRequests.clear();
|
||||
|
||||
existing.ws.close(1000, 'Replaced by new connection');
|
||||
// Immediately destroy TCP socket — no graceful close needed for replaced connections
|
||||
if (typeof existing.ws.terminate === 'function') {
|
||||
existing.ws.terminate();
|
||||
} else {
|
||||
existing.ws.close(1000, 'Replaced by new connection');
|
||||
}
|
||||
}
|
||||
|
||||
const connection: EdgeConnection = {
|
||||
@@ -418,7 +445,7 @@ export function handleEdgeConnection(
|
||||
hostname: hello.hostname,
|
||||
capabilities: hello.capabilities,
|
||||
connectedAt: new Date(),
|
||||
lastHeartbeat: new Date(),
|
||||
lastHeartbeat: Date.now(),
|
||||
pendingRequests: new Map(),
|
||||
pendingStreamRequests: new Map()
|
||||
};
|
||||
@@ -471,7 +498,8 @@ export async function sendEdgeRequest(
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
streaming = false,
|
||||
timeout = 30000
|
||||
timeout = 30000,
|
||||
isBinary = false
|
||||
): Promise<EdgeResponse> {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (!connection) {
|
||||
@@ -559,7 +587,8 @@ export async function sendEdgeRequest(
|
||||
method,
|
||||
path,
|
||||
headers: headers || {},
|
||||
body: body, // Body is already an object, will be serialized by JSON.stringify(message)
|
||||
body: body,
|
||||
isBinary, // true when body is base64-encoded binary (tar uploads)
|
||||
streaming
|
||||
};
|
||||
|
||||
@@ -753,7 +782,7 @@ export function handleEdgeResponse(environmentId: number, response: ResponseMess
|
||||
export function handleHeartbeat(environmentId: number): void {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (connection) {
|
||||
connection.lastHeartbeat = new Date();
|
||||
connection.lastHeartbeat = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +853,8 @@ export interface RequestMessage {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // JSON-serializable object, will be serialized when message is stringified
|
||||
body?: unknown; // JSON-serializable object, or base64 string when isBinary=true
|
||||
isBinary?: boolean; // true when body is base64-encoded binary data (tar uploads)
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
@@ -946,3 +976,269 @@ export type HawserMessage =
|
||||
| ContainerEventMessage
|
||||
| { type: 'ping'; timestamp: number }
|
||||
| { type: 'pong'; timestamp: number };
|
||||
|
||||
// ─── Production WebSocket message handler (used by server.js) ───
|
||||
|
||||
// Maps WebSocket instances to environment IDs for message routing
|
||||
const wsToEnvId = new Map<any, number>();
|
||||
|
||||
// Auth fail cache to prevent brute-force token validation.
|
||||
// Entries are periodically cleaned up to prevent unbounded growth.
|
||||
const hawserAuthFailCache = new Map<string, number>();
|
||||
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 30_000;
|
||||
|
||||
// Periodic cleanup of expired auth fail entries (every 60s)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of hawserAuthFailCache) {
|
||||
if (now - timestamp > HAWSER_AUTH_FAIL_COOLDOWN_MS) {
|
||||
hawserAuthFailCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Reconnection storm throttle ───
|
||||
// Tracks per-environment reconnection frequency to detect storms
|
||||
// (e.g., agent can auth but Docker socket is broken → 30s timeout → reconnect loop)
|
||||
interface ReconnectTrackerEntry {
|
||||
timestamps: number[];
|
||||
cooldownUntil: number; // 0 = no cooldown active
|
||||
cooldownLevel: number; // index into COOLDOWN_LEVELS
|
||||
}
|
||||
const reconnectTracker = new Map<number, ReconnectTrackerEntry>();
|
||||
const RECONNECT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
|
||||
const RECONNECT_BURST = 3; // allow 3 reconnections per window
|
||||
const COOLDOWN_LEVELS_SECS = [30, 60, 120, 300]; // escalating cooldown in seconds
|
||||
const STABLE_THRESHOLD_MS = 5 * 60 * 1000; // stable connection resets tracker
|
||||
const STALE_TRACKER_MS = 10 * 60 * 1000; // clean up stale tracker entries
|
||||
|
||||
/**
|
||||
* Record a reconnection for an environment and check if throttling is needed.
|
||||
* Returns { allowed: true } or { allowed: false, retryAfter: seconds }.
|
||||
*/
|
||||
function recordReconnection(envId: number): { allowed: true } | { allowed: false; retryAfter: number } {
|
||||
const now = Date.now();
|
||||
let entry = reconnectTracker.get(envId);
|
||||
|
||||
if (!entry) {
|
||||
entry = { timestamps: [now], cooldownUntil: 0, cooldownLevel: 0 };
|
||||
reconnectTracker.set(envId, entry);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check if currently in cooldown
|
||||
if (now < entry.cooldownUntil) {
|
||||
const retryAfter = Math.ceil((entry.cooldownUntil - now) / 1000);
|
||||
return { allowed: false, retryAfter };
|
||||
}
|
||||
|
||||
// Prune timestamps outside the sliding window
|
||||
entry.timestamps = entry.timestamps.filter(ts => now - ts < RECONNECT_WINDOW_MS);
|
||||
entry.timestamps.push(now);
|
||||
|
||||
// Check if burst limit exceeded
|
||||
if (entry.timestamps.length > RECONNECT_BURST) {
|
||||
const level = Math.min(entry.cooldownLevel, COOLDOWN_LEVELS_SECS.length - 1);
|
||||
const cooldownSecs = COOLDOWN_LEVELS_SECS[level];
|
||||
entry.cooldownUntil = now + cooldownSecs * 1000;
|
||||
entry.cooldownLevel = Math.min(entry.cooldownLevel + 1, COOLDOWN_LEVELS_SECS.length - 1);
|
||||
|
||||
console.warn(
|
||||
`[Hawser WS] Reconnection storm detected for env ${envId}: ` +
|
||||
`${entry.timestamps.length} connections in ${RECONNECT_WINDOW_MS / 1000}s. ` +
|
||||
`Cooldown ${cooldownSecs}s (level ${level})`
|
||||
);
|
||||
|
||||
return { allowed: false, retryAfter: cooldownSecs };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a WebSocket message from a Hawser Edge agent.
|
||||
* Full protocol handler: hello/welcome auth, ping/pong,
|
||||
* response/stream routing, metrics, container events, exec sessions.
|
||||
*
|
||||
* Registered as globalThis.__hawserHandleMessage for server.js to call.
|
||||
*/
|
||||
async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise<void> {
|
||||
if (msg.type === 'hello') {
|
||||
const remoteAddr = connId;
|
||||
|
||||
// Rate limit auth failures
|
||||
const lastFail = hawserAuthFailCache.get(remoteAddr);
|
||||
if (lastFail && Date.now() - lastFail < HAWSER_AUTH_FAIL_COOLDOWN_MS) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Too many failed attempts' }));
|
||||
ws.close(1008, 'Rate limited');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.token) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'No token provided' }));
|
||||
ws.close(1008, 'Missing token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateHawserToken(msg.token);
|
||||
if (!result.valid || !result.environmentId) {
|
||||
console.log(`[Hawser WS] Authentication failed for connection ${connId}`);
|
||||
hawserAuthFailCache.set(remoteAddr, Date.now());
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
ws.close(1008, 'Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle reconnection storms (successful auth but broken Docker = rapid reconnect loop)
|
||||
const throttle = recordReconnection(result.environmentId);
|
||||
if (!throttle.allowed) {
|
||||
console.log(`[Hawser WS] Throttling reconnection for env ${result.environmentId}: retry after ${throttle.retryAfter}s`);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: `Reconnection throttled. Retry after ${throttle.retryAfter}s.`,
|
||||
retryAfter: throttle.retryAfter
|
||||
}));
|
||||
ws.close(1008, 'Reconnection throttled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated — register the connection
|
||||
const connection = handleEdgeConnection(ws, result.environmentId, msg);
|
||||
wsToEnvId.set(ws, result.environmentId);
|
||||
|
||||
// Send welcome
|
||||
ws.send(JSON.stringify({
|
||||
type: 'welcome',
|
||||
serverId: 'dockhand',
|
||||
version: HAWSER_PROTOCOL_VERSION
|
||||
}));
|
||||
|
||||
console.log(`[Hawser WS] Agent authenticated: env=${result.environmentId} agent=${msg.agentName || msg.agentId}`);
|
||||
} catch (error: any) {
|
||||
console.error('[Hawser WS] Auth error:', error.message);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authentication failed' }));
|
||||
ws.close(1011, 'Auth error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other messages require an authenticated connection
|
||||
const envId = wsToEnvId.get(ws);
|
||||
if (!envId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = edgeConnections.get(envId);
|
||||
if (!connection) return;
|
||||
|
||||
// Update heartbeat
|
||||
connection.lastHeartbeat = Date.now();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'response': {
|
||||
const pending = connection.pendingRequests.get(msg.requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
connection.pendingRequests.delete(msg.requestId);
|
||||
pending.resolve({
|
||||
statusCode: msg.statusCode,
|
||||
headers: msg.headers || {},
|
||||
body: msg.body,
|
||||
isBinary: msg.isBinary
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream': {
|
||||
const streamPending = connection.pendingStreamRequests.get(msg.requestId);
|
||||
if (streamPending) {
|
||||
streamPending.onData?.(msg.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const streamReq = connection.pendingStreamRequests.get(msg.requestId);
|
||||
if (streamReq) {
|
||||
connection.pendingStreamRequests.delete(msg.requestId);
|
||||
streamReq.onEnd?.(msg.reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'metrics':
|
||||
if (globalThis.__hawserHandleMetrics) {
|
||||
await globalThis.__hawserHandleMetrics(envId, msg.metrics);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'container_event':
|
||||
if (globalThis.__hawserHandleContainerEvent) {
|
||||
await globalThis.__hawserHandleContainerEvent(envId, msg.event);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'exec_ready':
|
||||
case 'exec_output':
|
||||
case 'exec_end':
|
||||
// Forward exec messages to server.js/vite.config.ts via global callback
|
||||
if (globalThis.__terminalHandleExecMessage) {
|
||||
globalThis.__terminalHandleExecMessage(msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error(`[Hawser WS] Agent error (env ${envId}): ${msg.message}`);
|
||||
// Forward exec-related errors (identified by requestId) to terminal handler
|
||||
if (msg.requestId && globalThis.__terminalHandleExecMessage) {
|
||||
globalThis.__terminalHandleExecMessage(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket disconnect for a Hawser Edge agent.
|
||||
* Receives the actual ws object to correctly identify which connection closed.
|
||||
*/
|
||||
function handleHawserWsDisconnect(disconnectedWs: any, connId: string): void {
|
||||
const envId = wsToEnvId.get(disconnectedWs);
|
||||
if (!envId) {
|
||||
// This ws was never authenticated (e.g., auth failed), nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = edgeConnections.get(envId);
|
||||
if (connection && connection.ws === disconnectedWs) {
|
||||
console.log(`[Hawser WS] Agent disconnected: env=${envId}`);
|
||||
|
||||
for (const [, pending] of connection.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Agent disconnected'));
|
||||
}
|
||||
for (const [, pending] of connection.pendingStreamRequests) {
|
||||
pending.onEnd?.('Agent disconnected');
|
||||
}
|
||||
connection.pendingRequests.clear();
|
||||
connection.pendingStreamRequests.clear();
|
||||
|
||||
edgeConnections.delete(envId);
|
||||
updateEnvironmentStatus(envId, null);
|
||||
}
|
||||
|
||||
wsToEnvId.delete(disconnectedWs);
|
||||
}
|
||||
|
||||
// Register global handlers for server.js to call
|
||||
globalThis.__hawserHandleMessage = handleHawserWsMessage;
|
||||
globalThis.__hawserHandleDisconnect = handleHawserWsDisconnect;
|
||||
|
||||
+83
-11
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Cache the host data dir to avoid repeated API calls
|
||||
@@ -29,6 +30,11 @@ let detectionAttempted = false;
|
||||
// Cache ALL mounts for path translation (not just DATA_DIR)
|
||||
let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
|
||||
// Cache Dockhand's own Docker access method (detected from container inspect)
|
||||
// Used by scanner to replicate how Dockhand connects to Docker
|
||||
let cachedOwnDockerHost: string | null = null;
|
||||
let cachedOwnNetworkMode: string | null = null;
|
||||
|
||||
/**
|
||||
* Get our own container ID
|
||||
*/
|
||||
@@ -95,25 +101,48 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
|
||||
try {
|
||||
// Query Docker API to inspect our own container
|
||||
// Try unix socket first, fall back to TCP if DOCKER_HOST is set
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
const dockerHost = process.env.DOCKER_HOST;
|
||||
|
||||
// Use fetch with unix socket
|
||||
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const containerInfo = await new Promise<any>((resolvePromise, reject) => {
|
||||
const reqOptions: http.RequestOptions = dockerHost?.startsWith('tcp://')
|
||||
? (() => {
|
||||
const u = new URL(dockerHost.replace('tcp://', 'http://'));
|
||||
return { hostname: u.hostname, port: u.port, path: `/containers/${containerId}/json`, method: 'GET' };
|
||||
})()
|
||||
: { socketPath, path: `/containers/${containerId}/json`, method: 'GET' };
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[HostPath] Failed to inspect container: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerInfo = await response.json() as {
|
||||
const req = http.request(reqOptions, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse container inspect response'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Container inspect failed: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
}) as {
|
||||
Mounts?: Array<{
|
||||
Type: string;
|
||||
Source: string;
|
||||
Destination: string;
|
||||
}>;
|
||||
Config?: {
|
||||
Env?: string[];
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
// Cache ALL mounts for later path translation (used by rewriteComposeVolumePaths)
|
||||
@@ -123,6 +152,30 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
}));
|
||||
console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`);
|
||||
|
||||
// Cache DOCKER_HOST from Dockhand's own env vars (if set)
|
||||
// This tells us how Dockhand was configured to reach Docker
|
||||
const envVars = containerInfo.Config?.Env || [];
|
||||
for (const v of envVars) {
|
||||
if (v.startsWith('DOCKER_HOST=')) {
|
||||
cachedOwnDockerHost = v.substring('DOCKER_HOST='.length);
|
||||
console.log(`[HostPath] Detected own DOCKER_HOST: ${cachedOwnDockerHost}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Dockhand's network (prefer non-default for service discovery)
|
||||
const networks = containerInfo.NetworkSettings?.Networks;
|
||||
if (networks) {
|
||||
const custom = Object.keys(networks).filter(
|
||||
n => n !== 'bridge' && n !== 'none' && n !== 'host'
|
||||
);
|
||||
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
||||
: networks.bridge ? 'bridge' : null;
|
||||
if (cachedOwnNetworkMode) {
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the mount for our DATA_DIR
|
||||
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
|
||||
|
||||
@@ -157,6 +210,25 @@ export function getHostDataDir(): string | null {
|
||||
return cachedHostDataDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOCKER_HOST from Dockhand's own container config (if set).
|
||||
* Returns the TCP address (e.g., "tcp://socket-proxy:2375") or null.
|
||||
* Populated by detectHostDataDir() at startup.
|
||||
*/
|
||||
export function getOwnDockerHost(): string | null {
|
||||
return cachedOwnDockerHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Docker network Dockhand is attached to.
|
||||
* Used to place scanner containers on the same network so they can reach
|
||||
* TCP-based Docker endpoints (e.g., socket proxy).
|
||||
* Populated by detectHostDataDir() at startup.
|
||||
*/
|
||||
export function getOwnNetworkMode(): string | null {
|
||||
return cachedOwnNetworkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a container path to host path
|
||||
*
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface JobLine {
|
||||
event?: string; // 'result', 'progress', etc. — undefined for bare data lines
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
lines: JobLine[];
|
||||
result?: unknown;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const jobs = new Map<string, Job>();
|
||||
|
||||
export function createJob(): Job {
|
||||
const job: Job = {
|
||||
id: randomUUID(),
|
||||
status: 'running',
|
||||
lines: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJob(id: string): Job | undefined {
|
||||
return jobs.get(id);
|
||||
}
|
||||
|
||||
export function appendLine(job: Job, line: JobLine): void {
|
||||
job.lines.push(line);
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function completeJob(job: Job, result: unknown): void {
|
||||
job.result = result;
|
||||
job.status = 'done';
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function failJob(job: Job, error: string): void {
|
||||
job.result = { success: false, error };
|
||||
job.status = 'error';
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
// Cleanup jobs older than 10 minutes that are no longer running
|
||||
const CLEANUP_INTERVAL_MS = 60_000;
|
||||
const JOB_TTL_MS = 10 * 60_000;
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - JOB_TTL_MS;
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.status !== 'running' && job.updatedAt < cutoff) {
|
||||
jobs.delete(id);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* In-Memory Metrics Ring Buffer
|
||||
*
|
||||
* Replaces SQLite/PostgreSQL host_metrics storage with a fixed-size
|
||||
* in-memory circular buffer per environment. Uses pre-allocated arrays
|
||||
* with head/count indices to avoid splice()-based eviction which causes
|
||||
* V8 to repeatedly reallocate backing arrays.
|
||||
*
|
||||
* Memory: 16 envs × 360 slots × ~100 bytes ≈ 576 KB
|
||||
*/
|
||||
|
||||
export interface MetricPoint {
|
||||
id: number;
|
||||
cpuPercent: number;
|
||||
memoryPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryTotal: number;
|
||||
environmentId: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const MAX_POINTS_PER_ENV = 360; // 1 hour at 10s interval, 3 hours at 30s
|
||||
|
||||
interface RingBuffer {
|
||||
data: (MetricPoint | null)[];
|
||||
head: number; // next write position
|
||||
count: number; // number of valid entries (≤ MAX_POINTS_PER_ENV)
|
||||
}
|
||||
|
||||
// envId → RingBuffer
|
||||
const store = new Map<number, RingBuffer>();
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
/**
|
||||
* Push a new metric data point for an environment.
|
||||
* Overwrites oldest entry when buffer is full (no array reallocation).
|
||||
*/
|
||||
export function pushMetric(
|
||||
envId: number,
|
||||
cpuPercent: number,
|
||||
memoryPercent: number,
|
||||
memoryUsed: number,
|
||||
memoryTotal: number
|
||||
): void {
|
||||
let ring = store.get(envId);
|
||||
if (!ring) {
|
||||
ring = { data: new Array(MAX_POINTS_PER_ENV).fill(null), head: 0, count: 0 };
|
||||
store.set(envId, ring);
|
||||
}
|
||||
|
||||
ring.data[ring.head] = {
|
||||
id: nextId++,
|
||||
cpuPercent,
|
||||
memoryPercent,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
environmentId: envId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ring.head = (ring.head + 1) % MAX_POINTS_PER_ENV;
|
||||
if (ring.count < MAX_POINTS_PER_ENV) ring.count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read entries from a ring buffer in oldest-first order.
|
||||
*/
|
||||
function readRing(ring: RingBuffer, limit: number): MetricPoint[] {
|
||||
const count = Math.min(ring.count, limit);
|
||||
if (count === 0) return [];
|
||||
|
||||
const result: MetricPoint[] = new Array(count);
|
||||
// Start reading from the oldest entry
|
||||
const start = (ring.head - ring.count + MAX_POINTS_PER_ENV) % MAX_POINTS_PER_ENV;
|
||||
const skip = ring.count - count;
|
||||
const readFrom = (start + skip) % MAX_POINTS_PER_ENV;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = ring.data[(readFrom + i) % MAX_POINTS_PER_ENV]!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent metric for an environment.
|
||||
*/
|
||||
export function getLatestMetric(envId: number): MetricPoint | null {
|
||||
const ring = store.get(envId);
|
||||
if (!ring || ring.count === 0) return null;
|
||||
// head points to next write position, so latest is head - 1
|
||||
const idx = (ring.head - 1 + MAX_POINTS_PER_ENV) % MAX_POINTS_PER_ENV;
|
||||
return ring.data[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics history for an environment, oldest first.
|
||||
*/
|
||||
export function getMetricsHistory(envId: number, limit = 60): MetricPoint[] {
|
||||
const ring = store.get(envId);
|
||||
if (!ring || ring.count === 0) return [];
|
||||
return readRing(ring, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics (across all environments), newest first, with optional limit.
|
||||
* Used by the global getHostMetrics() fallback when no envId is specified.
|
||||
*/
|
||||
export function getAllMetrics(limit = 60): MetricPoint[] {
|
||||
const all: MetricPoint[] = [];
|
||||
for (const ring of store.values()) {
|
||||
const points = readRing(ring, ring.count);
|
||||
all.push(...points);
|
||||
}
|
||||
// Sort newest first (matching old DB query behavior)
|
||||
all.sort((a, b) => b.id - a.id);
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics for an environment (e.g., when environment is deleted).
|
||||
*/
|
||||
export function clearEnvironmentMetrics(envId: number): void {
|
||||
store.delete(envId);
|
||||
}
|
||||
@@ -21,6 +21,13 @@ function escapeTelegramMarkdown(text: string): string {
|
||||
.replace(/`/g, '\\`'); // Backtick (code)
|
||||
}
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -35,21 +42,47 @@ export interface NotificationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
|
||||
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
|
||||
|
||||
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
|
||||
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
|
||||
const cached = transporterCache.get(key);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.transporter;
|
||||
}
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
transporterCache.set(key, { transporter, lastUsed: Date.now() });
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Clean up idle transporters every 10 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of transporterCache) {
|
||||
if (now - entry.lastUsed > 10 * 60 * 1000) {
|
||||
entry.transporter.close();
|
||||
transporterCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
const transporter = getOrCreateTransporter(config);
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
@@ -172,6 +205,7 @@ async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Pr
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -204,6 +238,7 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -211,7 +246,7 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
|
||||
}
|
||||
|
||||
// Mattermost webhook
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
@@ -230,8 +265,7 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
console.error('[Notifications] Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token');
|
||||
return false;
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
@@ -249,13 +283,22 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
@@ -290,6 +333,7 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -328,6 +372,7 @@ async function sendGotify(appriseUrl: string, payload: NotificationPayload): Pro
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -394,6 +439,7 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -428,6 +474,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
@@ -455,6 +502,7 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* RSS Tracker — Per-operation native memory delta tracking
|
||||
*
|
||||
* Measures process.memoryUsage().rss before and after instrumented operations
|
||||
* to identify which background operation is responsible for native memory growth.
|
||||
*
|
||||
* All functions are no-ops when MEMORY_MONITOR !== 'true'.
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8';
|
||||
import { existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CategoryStats {
|
||||
count: number;
|
||||
totalDelta: number;
|
||||
maxDelta: number;
|
||||
// Lifetime cumulative (never reset)
|
||||
lifetimeCount: number;
|
||||
lifetimeTotalDelta: number;
|
||||
}
|
||||
|
||||
interface RssSnapshot {
|
||||
filename: string;
|
||||
timestamp: string;
|
||||
uptimeMin: number;
|
||||
rssMB: number;
|
||||
sizeMB: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const enabled = process.env.MEMORY_MONITOR === 'true';
|
||||
|
||||
const categories = new Map<string, CategoryStats>();
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let snapshotIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let periodNumber = 0;
|
||||
let periodStartRss = 0;
|
||||
const startupTime = Date.now();
|
||||
const startupRss = enabled ? process.memoryUsage().rss : 0;
|
||||
|
||||
// Snapshot settings
|
||||
const SNAPSHOT_WARMUP_MS = 5 * 60 * 1000; // 5 min before first snapshot
|
||||
const SNAPSHOT_INTERVAL_MS = parseInt(process.env.SNAPSHOT_INTERVAL || '60', 10) * 60 * 1000;
|
||||
const MAX_SNAPSHOTS = 48;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Capture RSS before an operation. Returns the RSS value.
|
||||
* No-op (returns 0) when MEMORY_MONITOR is not set.
|
||||
*/
|
||||
export function rssBeforeOp(): number {
|
||||
if (!enabled) return 0;
|
||||
return process.memoryUsage().rss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record RSS delta after an operation.
|
||||
* No-op when MEMORY_MONITOR is not set.
|
||||
*/
|
||||
export function rssAfterOp(category: string, before: number): void {
|
||||
if (!enabled || before === 0) return;
|
||||
|
||||
const after = process.memoryUsage().rss;
|
||||
const delta = after - before;
|
||||
|
||||
let stats = categories.get(category);
|
||||
if (!stats) {
|
||||
stats = { count: 0, totalDelta: 0, maxDelta: 0, lifetimeCount: 0, lifetimeTotalDelta: 0 };
|
||||
categories.set(category, stats);
|
||||
}
|
||||
|
||||
stats.count++;
|
||||
stats.totalDelta += delta;
|
||||
if (Math.abs(delta) > Math.abs(stats.maxDelta)) stats.maxDelta = delta;
|
||||
|
||||
stats.lifetimeCount++;
|
||||
stats.lifetimeTotalDelta += delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stats summary.
|
||||
*/
|
||||
export function getRssStats() {
|
||||
if (!enabled) return null;
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowth = mem.rss - startupRss;
|
||||
|
||||
const perCategory: Record<string, {
|
||||
count: number;
|
||||
avgDelta: string;
|
||||
maxDelta: string;
|
||||
totalDelta: string;
|
||||
lifetimeCount: number;
|
||||
lifetimeTotalDelta: string;
|
||||
}> = {};
|
||||
|
||||
for (const [cat, stats] of categories) {
|
||||
perCategory[cat] = {
|
||||
count: stats.count,
|
||||
avgDelta: stats.count > 0 ? fmtBytes(Math.round(stats.totalDelta / stats.count)) : '0',
|
||||
maxDelta: fmtBytes(stats.maxDelta),
|
||||
totalDelta: fmtBytes(stats.totalDelta),
|
||||
lifetimeCount: stats.lifetimeCount,
|
||||
lifetimeTotalDelta: fmtBytes(stats.lifetimeTotalDelta),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
periodNumber,
|
||||
rssMB: fmtMB(mem.rss),
|
||||
rssGrowthTotal: fmtBytes(rssGrowth),
|
||||
rssGrowthPerHour: fmtBytes(uptimeHours > 0.01 ? rssGrowth / uptimeHours : 0),
|
||||
uptimeHours: Math.round(uptimeHours * 100) / 100,
|
||||
categories: perCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Periodic logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logPeriodSummary(): void {
|
||||
periodNumber++;
|
||||
const mem = process.memoryUsage();
|
||||
const rssDelta = mem.rss - periodStartRss;
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowthTotal = mem.rss - startupRss;
|
||||
const rssPerHour = uptimeHours > 0.01 ? rssGrowthTotal / uptimeHours : 0;
|
||||
|
||||
let summary = `[RSS] #${periodNumber} rss=${fmtMB(mem.rss)}(${fmtDelta(rssDelta)}) total=${fmtDelta(rssGrowthTotal)} rate=${fmtBytes(Math.round(rssPerHour))}/h`;
|
||||
|
||||
// Sort categories by absolute totalDelta descending
|
||||
const sorted = [...categories.entries()]
|
||||
.filter(([, s]) => s.count > 0)
|
||||
.sort((a, b) => Math.abs(b[1].totalDelta) - Math.abs(a[1].totalDelta));
|
||||
|
||||
let accountedDelta = 0;
|
||||
for (const [cat, stats] of sorted) {
|
||||
const avg = stats.count > 0 ? Math.round(stats.totalDelta / stats.count) : 0;
|
||||
summary += `\n ${cat.padEnd(14)} n=${String(stats.count).padStart(4)} avg=${fmtDelta(avg).padStart(7)} max=${fmtDelta(stats.maxDelta).padStart(7)} total=${fmtDelta(stats.totalDelta).padStart(7)}`;
|
||||
accountedDelta += stats.totalDelta;
|
||||
}
|
||||
|
||||
const unaccounted = rssDelta - accountedDelta;
|
||||
if (sorted.length > 0) {
|
||||
summary += `\n ${'unaccounted'.padEnd(14)} ${fmtDelta(unaccounted).padStart(7)}`;
|
||||
}
|
||||
|
||||
console.log(summary);
|
||||
|
||||
// Reset per-period counters (keep lifetime)
|
||||
for (const stats of categories.values()) {
|
||||
stats.count = 0;
|
||||
stats.totalDelta = 0;
|
||||
stats.maxDelta = 0;
|
||||
}
|
||||
periodStartRss = mem.rss;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heap snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSnapshotDir(): string {
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
return join(dataDir, 'snapshots');
|
||||
}
|
||||
|
||||
function ensureSnapshotDir(): string {
|
||||
const dir = getSnapshotDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanupOldSnapshots(dir: string): void {
|
||||
try {
|
||||
const files = readdirSync(dir)
|
||||
.filter(f => f.endsWith('.heapsnapshot'))
|
||||
.map(f => ({ name: f, time: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
while (files.length > MAX_SNAPSHOTS) {
|
||||
const oldest = files.shift()!;
|
||||
try {
|
||||
unlinkSync(join(dir, oldest.name));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump a V8 heap snapshot to disk. Returns the filename.
|
||||
*/
|
||||
export function dumpHeapSnapshot(): string | null {
|
||||
if (!enabled) return null;
|
||||
|
||||
const dir = ensureSnapshotDir();
|
||||
cleanupOldSnapshots(dir);
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const uptimeMin = Math.round((Date.now() - startupTime) / 60000);
|
||||
const rssMB = Math.round(mem.rss / (1024 * 1024));
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').substring(0, 19);
|
||||
const filename = `heap-${ts}-${uptimeMin}m-${rssMB}mb.heapsnapshot`;
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
try {
|
||||
v8.writeHeapSnapshot(filepath);
|
||||
console.log(`[RSS] Heap snapshot saved: ${filepath}`);
|
||||
return filename;
|
||||
} catch (err) {
|
||||
console.error(`[RSS] Failed to write heap snapshot:`, err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List saved heap snapshots.
|
||||
*/
|
||||
export function listHeapSnapshots(): RssSnapshot[] {
|
||||
const dir = getSnapshotDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter(f => f.endsWith('.heapsnapshot'))
|
||||
.map(f => {
|
||||
const stat = statSync(join(dir, f));
|
||||
// Parse filename: heap-YYYY-MM-DD-HH-MM-SS-{uptime}m-{rss}mb.heapsnapshot
|
||||
const match = f.match(/heap-(.+?)-(\d+)m-(\d+)mb\.heapsnapshot/);
|
||||
return {
|
||||
filename: f,
|
||||
timestamp: stat.mtime.toISOString(),
|
||||
uptimeMin: match ? parseInt(match[2]) : 0,
|
||||
rssMB: match ? parseInt(match[3]) : 0,
|
||||
sizeMB: Math.round(stat.size / (1024 * 1024) * 10) / 10,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoSnapshots(): void {
|
||||
// First snapshot after warmup
|
||||
setTimeout(() => {
|
||||
dumpHeapSnapshot();
|
||||
|
||||
// Then every SNAPSHOT_INTERVAL_MS
|
||||
snapshotIntervalHandle = setInterval(() => {
|
||||
dumpHeapSnapshot();
|
||||
}, SNAPSHOT_INTERVAL_MS);
|
||||
}, SNAPSHOT_WARMUP_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the RSS tracker. Call once on startup.
|
||||
*/
|
||||
export function startRssTracker(): void {
|
||||
if (!enabled) return;
|
||||
|
||||
periodStartRss = process.memoryUsage().rss;
|
||||
console.log(`[RSS] Tracker started. Initial RSS: ${fmtMB(periodStartRss)}. Logging every 60s.`);
|
||||
|
||||
intervalHandle = setInterval(logPeriodSummary, 60_000);
|
||||
|
||||
startAutoSnapshots();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RSS tracker.
|
||||
*/
|
||||
export function stopRssTracker(): void {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
}
|
||||
if (snapshotIntervalHandle) {
|
||||
clearInterval(snapshotIntervalHandle);
|
||||
snapshotIntervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtMB(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
const abs = Math.abs(bytes);
|
||||
const sign = bytes < 0 ? '-' : '+';
|
||||
if (abs < 1024) return `${sign}${abs}B`;
|
||||
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)}K`;
|
||||
return `${sign}${(abs / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function fmtDelta(bytes: number): string {
|
||||
return fmtBytes(bytes);
|
||||
}
|
||||
+53
-34
@@ -15,7 +15,7 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath } from './host-path';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown } from 'node:fs/promises';
|
||||
|
||||
@@ -306,8 +306,18 @@ export function sanitizeJsonString(json: string): string {
|
||||
const ch2 = json[i];
|
||||
if ('"\\\/bfnrtu'.includes(ch2)) {
|
||||
result += ch2;
|
||||
} else if (ch < 0x20) {
|
||||
// Backslash followed by a raw control character (e.g. \ + 0x0A)
|
||||
// The backslash was already added to result — escape it as \\
|
||||
// then also escape the control character
|
||||
result += '\\';
|
||||
if (ch === 0x0A) result += '\\n';
|
||||
else if (ch === 0x0D) result += '\\r';
|
||||
else if (ch === 0x09) result += '\\t';
|
||||
else result += `\\u${ch.toString(16).padStart(4, '0')}`;
|
||||
sanitized++;
|
||||
} else {
|
||||
// Invalid escape like \x, \a, \0, \_ — convert backslash to literal \\
|
||||
// Invalid escape like \x, \a, \e — convert backslash to literal \\
|
||||
result += '\\' + ch2;
|
||||
sanitized++;
|
||||
}
|
||||
@@ -341,7 +351,7 @@ export function sanitizeJsonString(json: string): string {
|
||||
}
|
||||
|
||||
if (sanitized > 0) {
|
||||
console.warn(`[Scanner] Sanitized ${sanitized} control characters in JSON output`);
|
||||
console.warn(`[Scanner] Sanitized ${sanitized} control/escape characters in JSON output`);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -359,12 +369,10 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
|
||||
unknown: 0
|
||||
};
|
||||
|
||||
console.log('[Grype] Raw output length:', output.length);
|
||||
console.log('[Grype] Output starts with:', output.slice(0, 200));
|
||||
console.log('[Grype] Output ends with:', JSON.stringify(output.slice(-50)));
|
||||
|
||||
try {
|
||||
const data = JSON.parse(sanitizeJsonString(extractJson(output)));
|
||||
const extracted = extractJson(output);
|
||||
const sanitized = sanitizeJsonString(extracted);
|
||||
const data = JSON.parse(sanitized);
|
||||
|
||||
if (data.matches) {
|
||||
for (const match of data.matches) {
|
||||
@@ -587,42 +595,45 @@ async function runScannerContainerCore(
|
||||
const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy';
|
||||
const dbPath = basePath;
|
||||
|
||||
// Detect the host Docker socket path based on connection type
|
||||
// For local socket environments, detect the actual host socket path (handles rootless Docker)
|
||||
// For remote environments (hawser/direct with host), scanner runs remotely and uses standard path
|
||||
// Detect how the scanner container should access Docker.
|
||||
// Strategy: mirror Dockhand's own Docker connection when running locally.
|
||||
const env = envId ? await getEnvironment(envId) : undefined;
|
||||
const connectionType = env?.connectionType;
|
||||
|
||||
// Determine if this is a local socket environment:
|
||||
// - connectionType === 'socket' (explicit)
|
||||
// - connectionType is null/undefined (default behavior)
|
||||
// - connectionType === 'direct' but no host specified (legacy local environments)
|
||||
const isLocalSocket = !connectionType ||
|
||||
connectionType === 'socket' ||
|
||||
(connectionType === 'direct' && !env?.host);
|
||||
const isHawser = connectionType === 'hawser-standard' || connectionType === 'hawser-edge';
|
||||
|
||||
let hostSocketPath: string;
|
||||
let hostSocketPath: string | null = null;
|
||||
let rootlessUid: string | undefined;
|
||||
let scannerNetworkMode: string | undefined;
|
||||
let scannerDockerHost: string | undefined;
|
||||
|
||||
if (isLocalSocket) {
|
||||
// Local socket environment - detect host socket path (handles rootless Docker)
|
||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||
// Detected at startup from Dockhand's own container inspect data.
|
||||
// This applies to ALL non-hawser environments since the scanner container
|
||||
// runs on the same Docker daemon and needs the same access method.
|
||||
const ownDockerHost = getOwnDockerHost();
|
||||
|
||||
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
||||
scannerDockerHost = ownDockerHost;
|
||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||
console.log(`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`);
|
||||
} else if (isHawser) {
|
||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
// For user-specific Docker sockets (rootless Docker), detect UID for cache ownership
|
||||
// but do NOT set container user — in rootless Docker, root inside the container
|
||||
// maps to the socket-owning UID on the host via user namespace remapping
|
||||
const uid = extractUidFromSocketPath(hostSocketPath);
|
||||
if (uid) {
|
||||
rootlessUid = uid;
|
||||
console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`);
|
||||
console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`);
|
||||
}
|
||||
} else {
|
||||
// Remote environment (direct with host/hawser-standard/hawser-edge)
|
||||
// Scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan (${connectionType}, host: ${env?.host}) - using standard socket path: ${hostSocketPath}`);
|
||||
}
|
||||
|
||||
// Determine cache storage strategy based on environment
|
||||
@@ -643,10 +654,12 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] Standard mode - using volume: ${volumeName}`);
|
||||
}
|
||||
|
||||
const binds = [
|
||||
`${hostSocketPath}:/var/run/docker.sock:ro`,
|
||||
cacheBind
|
||||
];
|
||||
// Build binds — only include socket mount when using socket mode
|
||||
const binds: string[] = [];
|
||||
if (hostSocketPath) {
|
||||
binds.push(`${hostSocketPath}:/var/run/docker.sock:ro`);
|
||||
}
|
||||
binds.push(cacheBind);
|
||||
|
||||
console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`);
|
||||
|
||||
@@ -655,6 +668,11 @@ async function runScannerContainerCore(
|
||||
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
|
||||
: [`TRIVY_CACHE_DIR=${dbPath}`];
|
||||
|
||||
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
|
||||
if (scannerDockerHost) {
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
}
|
||||
|
||||
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
|
||||
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
|
||||
@@ -665,6 +683,7 @@ async function runScannerContainerCore(
|
||||
env: envVars,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
envId,
|
||||
networkMode: scannerNetworkMode,
|
||||
timeout: 600_000, // 10 minutes
|
||||
onStderr: (data) => {
|
||||
// Stream stderr lines for real-time progress output
|
||||
@@ -680,8 +699,8 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||
if (output.length === 0) {
|
||||
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
|
||||
console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker`);
|
||||
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
|
||||
} else if (output.length < 100) {
|
||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||
}
|
||||
|
||||
@@ -569,9 +569,9 @@ export async function runContainerUpdate(
|
||||
// =============================================================================
|
||||
|
||||
log(`Recreating container with full config passthrough...`);
|
||||
const success = await recreateContainer(containerName, envId, log, imageNameFromConfig);
|
||||
const result = await recreateContainer(containerName, envId, log, imageNameFromConfig);
|
||||
|
||||
if (success) {
|
||||
if (result.success) {
|
||||
await updateAutoUpdateLastUpdated(containerName, envId);
|
||||
log(`Successfully updated container: ${containerName}`);
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function runContainerUpdate(
|
||||
type: 'success'
|
||||
}, envId);
|
||||
} else {
|
||||
throw new Error('Failed to recreate container');
|
||||
throw new Error(result.error || 'Failed to recreate container');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -628,14 +628,14 @@ export async function recreateContainer(
|
||||
envId?: number,
|
||||
log?: (msg: string) => void,
|
||||
imageNameOverride?: string
|
||||
): Promise<boolean> {
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const containers = await listContainers(true, envId);
|
||||
const container = containers.find(c => c.name === containerName);
|
||||
|
||||
if (!container) {
|
||||
log?.(`Container not found: ${containerName}`);
|
||||
return false;
|
||||
return { success: false, error: `Container not found: ${containerName}` };
|
||||
}
|
||||
|
||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||
@@ -644,10 +644,10 @@ export async function recreateContainer(
|
||||
log?.(`Recreating container: ${containerName} (image: ${imageName})`);
|
||||
|
||||
await recreateContainerFromInspect(inspectData, imageName, envId, log);
|
||||
return true;
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log?.(`Failed to recreate container: ${error.message}`);
|
||||
return false;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,9 +352,9 @@ export async function runEnvUpdateCheckJob(
|
||||
|
||||
// Recreate container with full config passthrough
|
||||
await log(` Recreating container...`);
|
||||
const ok = await recreateContainer(update.containerName, environmentId,
|
||||
const result = await recreateContainer(update.containerName, environmentId,
|
||||
(msg) => { log(` ${msg}`); });
|
||||
if (!ok) throw new Error('Container recreation failed');
|
||||
if (!result.success) throw new Error(result.error || 'Container recreation failed');
|
||||
|
||||
await log(` Updated successfully`);
|
||||
successCount++;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if the client prefers JSON over SSE.
|
||||
* Returns true when Accept header includes application/json but NOT text/event-stream.
|
||||
*/
|
||||
export function prefersJSON(request?: Request): boolean {
|
||||
const accept = request?.headers.get('accept') || '';
|
||||
return accept.includes('application/json') && !accept.includes('text/event-stream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an SSE Response for JSON-preferring clients.
|
||||
*
|
||||
* Consumes the SSE stream using proper event framing (blank-line delimited,
|
||||
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
|
||||
* data as a JSON response, or a fallback if no result event was emitted.
|
||||
*
|
||||
* Usage:
|
||||
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
|
||||
* return buildSSEResponse();
|
||||
*/
|
||||
export async function sseToJSON(sseResponse: Response): Promise<Response> {
|
||||
const reader = sseResponse.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let dataLines: string[] = [];
|
||||
let resultData: unknown = null;
|
||||
|
||||
const dispatch = () => {
|
||||
const data = dataLines.join('\n');
|
||||
const type = eventType || 'message';
|
||||
eventType = '';
|
||||
dataLines = [];
|
||||
if (type === 'result' && data) {
|
||||
try {
|
||||
resultData = JSON.parse(data);
|
||||
} catch {
|
||||
// keep previous resultData
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseLine = (rawLine: string) => {
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line.startsWith(':')) return;
|
||||
if (line === '') { dispatch(); return; }
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let val = colon === -1 ? '' : line.slice(colon + 1);
|
||||
if (val.startsWith(' ')) val = val.slice(1);
|
||||
if (field === 'event') eventType = val || 'message';
|
||||
else if (field === 'data') dataLines.push(val);
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) parseLine(line);
|
||||
}
|
||||
|
||||
// Flush remaining bytes and process trailing content
|
||||
buffer += decoder.decode();
|
||||
if (buffer) {
|
||||
for (const line of buffer.split('\n')) parseLine(line);
|
||||
}
|
||||
// Final dispatch for servers missing trailing blank line
|
||||
if (dataLines.length > 0) dispatch();
|
||||
} catch {
|
||||
// stream error, return what we have
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = resultData ?? { success: false, error: 'No result' };
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Job-based response for long-running operations.
|
||||
*
|
||||
* Backward compat: API clients that send `Accept: application/json` (and not
|
||||
* `text/event-stream`) get a synchronous JSON result directly.
|
||||
*
|
||||
* All other clients receive `{ jobId }` immediately. The operation runs in the
|
||||
* background and results accumulate in the job store. Clients poll /api/jobs/{id}.
|
||||
*
|
||||
* The send() callback stores lines with { event, data } so the polling client
|
||||
* can reconstruct the same event stream semantics used by the old SSE flow.
|
||||
*/
|
||||
export function createJobResponse(
|
||||
operation: (send: (event: string, data: unknown) => void) => Promise<void>,
|
||||
request?: Request
|
||||
): Response {
|
||||
// Backward compat: synchronous JSON path for explicit application/json callers
|
||||
if (prefersJSON(request)) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let resultData: unknown = { success: false, error: 'No result' };
|
||||
const send = (_event: string, data: unknown) => {
|
||||
resultData = data;
|
||||
};
|
||||
try {
|
||||
await operation(send);
|
||||
} catch (error) {
|
||||
resultData = { success: false, error: String(error) };
|
||||
}
|
||||
controller.enqueue(encoder.encode(JSON.stringify(resultData)));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Fire and forget: create job, run operation in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const send = (event: string, data: unknown) => {
|
||||
appendLine(job, { event, data });
|
||||
};
|
||||
|
||||
operation(send)
|
||||
.then(() => {
|
||||
const resultLine = job.lines.findLast((l) => l.event === 'result');
|
||||
completeJob(job, resultLine?.data ?? { success: true });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Discovered stacks are editable - compose and .env files are modified in their original location.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
@@ -57,8 +57,7 @@ export function normalizeStackName(name: string): string {
|
||||
*/
|
||||
async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
// Basic check for services key - could be more sophisticated
|
||||
return /^services:/m.test(content) || /\nservices:/m.test(content);
|
||||
} catch {
|
||||
@@ -72,8 +71,7 @@ async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const doc = yaml.load(content) as Record<string, unknown> | null;
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
return Object.keys(doc.services).length;
|
||||
|
||||
+96
-49
@@ -7,6 +7,8 @@
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -178,13 +180,47 @@ async function withStackLock<T>(stackName: string, fn: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout configuration for compose operations
|
||||
const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
// Timeout configuration for compose operations (configurable via COMPOSE_TIMEOUT env var in seconds)
|
||||
const COMPOSE_TIMEOUT_MS = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; // Default 15 min
|
||||
const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL
|
||||
|
||||
/**
|
||||
* Check if content is binary (not valid UTF-8 text).
|
||||
*/
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
function isBinaryContent(bytes: Uint8Array): boolean {
|
||||
try {
|
||||
utf8Decoder.decode(bytes);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect stdout/stderr from a child process and wait for it to exit.
|
||||
*/
|
||||
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
stderr: Buffer.concat(stderrChunks).toString()
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all files from a directory as a map of relative path -> content.
|
||||
* Used to send files to Hawser for remote deployments.
|
||||
* Binary files are base64-encoded with a "base64:" prefix to preserve all bytes.
|
||||
*/
|
||||
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
@@ -200,9 +236,12 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
if (entry.name === '.git') continue;
|
||||
await scanDir(fullPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Read file content
|
||||
const content = await Bun.file(fullPath).text();
|
||||
files[relPath] = content;
|
||||
const bytes = readFileSync(fullPath);
|
||||
if (isBinaryContent(bytes)) {
|
||||
files[relPath] = `base64:${bytes.toString('base64')}`;
|
||||
} else {
|
||||
files[relPath] = new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +421,7 @@ export async function getStackComposeFile(
|
||||
};
|
||||
}
|
||||
|
||||
const content = await Bun.file(source.composePath).text();
|
||||
const content = readFileSync(source.composePath, 'utf-8');
|
||||
const stackDir = dirname(source.composePath);
|
||||
|
||||
// For custom paths, suggest .env next to compose if envPath not set
|
||||
@@ -420,15 +459,14 @@ export async function getStackComposeFile(
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const actualComposePath = join(stackDir, fileName);
|
||||
const file = Bun.file(actualComposePath);
|
||||
if (await file.exists()) {
|
||||
if (existsSync(actualComposePath)) {
|
||||
// Check for .env file in the same directory
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
const envExists = existsSync(envFilePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
content: readFileSync(actualComposePath, 'utf-8'),
|
||||
stackDir,
|
||||
// Always return the actual resolved paths for display
|
||||
composePath: actualComposePath,
|
||||
@@ -632,7 +670,7 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Bun.write(composePath, content);
|
||||
writeFileSync(composePath, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to save compose file: ${err.message}` };
|
||||
@@ -672,7 +710,7 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
|
||||
try {
|
||||
await Bun.write(composeFile, content);
|
||||
writeFileSync(composeFile, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` };
|
||||
@@ -712,26 +750,23 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['docker', 'login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
const proc = nodeSpawn(
|
||||
'docker', ['login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
{
|
||||
env: spawnEnv,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
// Write password to stdin (Bun's FileSink API)
|
||||
proc.stdin.write(reg.password);
|
||||
proc.stdin.end();
|
||||
// Write password to stdin
|
||||
proc.stdin!.write(reg.password);
|
||||
proc.stdin!.end();
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
const { exitCode, stderr } = await collectProcess(proc);
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`${logPrefix} Successfully logged into ${registryHost}`);
|
||||
} else {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -786,7 +821,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a docker compose command locally via Bun.spawn.
|
||||
* Execute a docker compose command locally via child_process.spawn.
|
||||
*
|
||||
* @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files)
|
||||
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
|
||||
@@ -834,7 +869,7 @@ async function executeLocalCompose(
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
composeFile = join(stackDir, 'compose.yaml');
|
||||
await Bun.write(composeFile, composeContent);
|
||||
writeFileSync(composeFile, composeContent);
|
||||
}
|
||||
|
||||
// Rewrite relative volume paths for host path translation (in memory only, not saved to disk)
|
||||
@@ -908,15 +943,15 @@ async function executeLocalCompose(
|
||||
// Write certs to files (docker-compose expects specific filenames)
|
||||
if (tlsConfig.ca) {
|
||||
const cleanedCa = cleanPem(tlsConfig.ca);
|
||||
if (cleanedCa) await Bun.write(join(tlsCertDir, 'ca.pem'), cleanedCa);
|
||||
if (cleanedCa) writeFileSync(join(tlsCertDir, 'ca.pem'), cleanedCa);
|
||||
}
|
||||
if (tlsConfig.cert) {
|
||||
const cleanedCert = cleanPem(tlsConfig.cert);
|
||||
if (cleanedCert) await Bun.write(join(tlsCertDir, 'cert.pem'), cleanedCert);
|
||||
if (cleanedCert) writeFileSync(join(tlsCertDir, 'cert.pem'), cleanedCert);
|
||||
}
|
||||
if (tlsConfig.key) {
|
||||
const cleanedKey = cleanPem(tlsConfig.key);
|
||||
if (cleanedKey) await Bun.write(join(tlsCertDir, 'key.pem'), cleanedKey);
|
||||
if (cleanedKey) writeFileSync(join(tlsCertDir, 'key.pem'), cleanedKey);
|
||||
}
|
||||
|
||||
// Set Docker TLS environment variables
|
||||
@@ -941,13 +976,13 @@ async function executeLocalCompose(
|
||||
// Also include override file if it exists (needs path translation too)
|
||||
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||
if (overrideFile) {
|
||||
let overrideContent = await Bun.file(overrideFile).text();
|
||||
let overrideContent = readFileSync(overrideFile, 'utf-8');
|
||||
if (getHostDataDir()) {
|
||||
const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir);
|
||||
if (rewrite.modified) overrideContent = rewrite.content;
|
||||
}
|
||||
tempOverridePath = join(stackDir, '.compose.override.translated.yaml');
|
||||
await Bun.write(tempOverridePath, overrideContent);
|
||||
writeFileSync(tempOverridePath, overrideContent);
|
||||
args.push('-f', tempOverridePath);
|
||||
console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`);
|
||||
}
|
||||
@@ -983,7 +1018,7 @@ async function executeLocalCompose(
|
||||
const overrideEnvPath = join(stackDir, '.env.dockhand');
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
await Bun.write(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
args.push('--env-file', overrideEnvPath);
|
||||
}
|
||||
|
||||
@@ -1001,7 +1036,7 @@ async function executeLocalCompose(
|
||||
}
|
||||
break;
|
||||
case 'down':
|
||||
args.push('down');
|
||||
args.push('down', '--remove-orphans');
|
||||
if (removeVolumes) args.push('--volumes');
|
||||
break;
|
||||
case 'stop':
|
||||
@@ -1045,12 +1080,10 @@ async function executeLocalCompose(
|
||||
|
||||
try {
|
||||
console.log(`${logPrefix} Spawning docker compose process...`);
|
||||
const proc = Bun.spawn(args, {
|
||||
const proc = nodeSpawn(args[0], args.slice(1), {
|
||||
cwd: stackDir,
|
||||
env: spawnEnv,
|
||||
stdin: useStdin ? 'pipe' : 'inherit',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// If using stdin (host path translation), write the modified compose content
|
||||
@@ -1077,12 +1110,7 @@ async function executeLocalCompose(
|
||||
}, COMPOSE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text()
|
||||
]);
|
||||
|
||||
const code = await proc.exited;
|
||||
const { exitCode: code, stdout, stderr } = await collectProcess(proc);
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`);
|
||||
@@ -1343,7 +1371,7 @@ async function executeComposeCommand(
|
||||
let hawserEnvVars = envVars;
|
||||
if (envPath && existsSync(envPath)) {
|
||||
try {
|
||||
const envFileContent = await Bun.file(envPath).text();
|
||||
const envFileContent = readFileSync(envPath, 'utf-8');
|
||||
const envFileVars = parseEnvFileContent(envFileContent, stackName);
|
||||
// Merge: envFileVars (lowest) < envVars (DB overrides)
|
||||
// secretVars are handled separately in executeComposeViaHawser
|
||||
@@ -1362,7 +1390,7 @@ async function executeComposeCommand(
|
||||
const overridePath = findComposeOverrideFile(composeDir, composeBaseName);
|
||||
if (overridePath) {
|
||||
try {
|
||||
const overrideContent = await Bun.file(overridePath).text();
|
||||
const overrideContent = readFileSync(overridePath, 'utf-8');
|
||||
hawserStackFiles = { ...(hawserStackFiles || {}), [basename(overridePath)]: overrideContent };
|
||||
console.log(`[Stack:${stackName}] Including override file for Hawser: ${basename(overridePath)}`);
|
||||
} catch (err) {
|
||||
@@ -1371,6 +1399,16 @@ async function executeComposeCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// For git stacks: generate .env.dockhand with non-secret DB overrides
|
||||
// This mirrors executeLocalCompose behavior (lines 1017-1023).
|
||||
// envVars contains only the DB overrides (not merged repo .env values from hawserEnvVars).
|
||||
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
hawserStackFiles = { ...(hawserStackFiles || {}), '.env.dockhand': header + lines.join('\n') + '\n' };
|
||||
console.log(`[Stack:${stackName}] Including .env.dockhand override file for Hawser (${Object.keys(envVars).length} vars)`);
|
||||
}
|
||||
|
||||
return executeComposeViaHawser(
|
||||
operation,
|
||||
stackName,
|
||||
@@ -1766,8 +1804,9 @@ async function requireComposeFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stack using docker compose up
|
||||
* Falls back to individual container start for stacks without compose files
|
||||
* Start a stack using docker compose start (resumes stopped containers).
|
||||
* Falls back to docker compose up if containers don't exist (stack was removed/down).
|
||||
* Falls back to individual container start for stacks without compose files.
|
||||
*/
|
||||
export async function startStack(
|
||||
stackName: string,
|
||||
@@ -1780,9 +1819,17 @@ export async function startStack(
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
const opts = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
|
||||
|
||||
// Check if containers exist for this stack. If they do, use 'start' to resume
|
||||
// them (preserves container IDs, avoids Traefik race conditions from recreation).
|
||||
// If no containers exist (stack was removed/down), use 'up' to create them.
|
||||
const containers = await getStackContainers(stackName, envId);
|
||||
const operation = containers.length > 0 ? 'start' : 'up';
|
||||
|
||||
return executeComposeCommand(
|
||||
'up',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
operation,
|
||||
opts,
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
@@ -2185,7 +2232,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
}
|
||||
if (actualEnvPath && existsSync(actualEnvPath) && !stackFiles['.env']) {
|
||||
try {
|
||||
const envContent = await Bun.file(actualEnvPath).text();
|
||||
const envContent = readFileSync(actualEnvPath, 'utf-8');
|
||||
stackFiles['.env'] = envContent;
|
||||
console.log(`${logPrefix} Added .env to stackFiles for Hawser (${envContent.length} chars)`);
|
||||
} catch (err) {
|
||||
@@ -2415,7 +2462,7 @@ export async function writeStackEnvFile(
|
||||
.map(v => `${v.key.trim()}=${v.value}`)
|
||||
.join('\n') + '\n';
|
||||
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
writeFileSync(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2453,7 +2500,7 @@ export async function writeRawStackEnvFile(
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
writeFileSync(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,687 +0,0 @@
|
||||
/**
|
||||
* Event Collection Subprocess
|
||||
*
|
||||
* Runs as a separate process via Bun.spawn to collect Docker container events
|
||||
* without blocking the main HTTP thread.
|
||||
*
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
|
||||
import { getDockerEvents } from '../docker';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
// Reconnection settings
|
||||
const RECONNECT_DELAY = 5000; // 5 seconds
|
||||
const MAX_RECONNECT_DELAY = 60000; // 1 minute max
|
||||
|
||||
// Track environment online status for notifications
|
||||
// Only send notifications on status CHANGES, not on every reconnect attempt
|
||||
const environmentOnlineStatus: Map<number, boolean> = new Map();
|
||||
|
||||
// Active collectors per environment (for streaming mode)
|
||||
const collectors: Map<number, { controller: AbortController; reconnectTimeout: ReturnType<typeof setTimeout> | null }> = new Map();
|
||||
|
||||
// Poll intervals per environment (for polling mode)
|
||||
const pollIntervals: Map<number, ReturnType<typeof setInterval>> = new Map();
|
||||
|
||||
// Last poll timestamp per environment (for polling mode)
|
||||
const lastPollTime: Map<number, number> = new Map();
|
||||
|
||||
// Recent event cache for deduplication (key: timeNano-containerId-action)
|
||||
const recentEvents: Map<string, number> = new Map();
|
||||
const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 5000; // Clean up cache every 5 seconds (match dedup window)
|
||||
const MAX_DEDUP_CACHE_SIZE = 500; // Hard limit to prevent unbounded growth
|
||||
|
||||
let cacheCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Track current settings to detect changes
|
||||
let currentPollInterval: number = 60000;
|
||||
let currentMode: 'stream' | 'poll' = 'stream';
|
||||
|
||||
// Actions we care about for container activity
|
||||
const CONTAINER_ACTIONS: ContainerEventAction[] = [
|
||||
'create',
|
||||
'start',
|
||||
'stop',
|
||||
'die',
|
||||
'kill',
|
||||
'restart',
|
||||
'pause',
|
||||
'unpause',
|
||||
'destroy',
|
||||
'rename',
|
||||
'update',
|
||||
'oom',
|
||||
'health_status'
|
||||
];
|
||||
|
||||
// Scanner image patterns to exclude from events
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
// Container name patterns to exclude from events
|
||||
const EXCLUDED_CONTAINER_PREFIXES = ['dockhand-browse-'];
|
||||
|
||||
/**
|
||||
* Send message to main process
|
||||
*/
|
||||
function send(message: any): void {
|
||||
if (process.send) {
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some((pattern) => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
function isExcludedContainer(containerName: string | null | undefined): boolean {
|
||||
if (!containerName) return false;
|
||||
return EXCLUDED_CONTAINER_PREFIXES.some((prefix) => containerName.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update environment online status and notify main process on change
|
||||
*/
|
||||
function updateEnvironmentStatus(
|
||||
envId: number,
|
||||
envName: string,
|
||||
isOnline: boolean,
|
||||
errorMessage?: string
|
||||
) {
|
||||
const previousStatus = environmentOnlineStatus.get(envId);
|
||||
|
||||
// Only send notification on status CHANGE (not on first connection or repeated failures)
|
||||
if (previousStatus !== undefined && previousStatus !== isOnline) {
|
||||
send({
|
||||
type: 'env_status',
|
||||
envId,
|
||||
envName,
|
||||
online: isOnline,
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
environmentOnlineStatus.set(envId, isOnline);
|
||||
}
|
||||
|
||||
interface DockerEvent {
|
||||
Type: string;
|
||||
Action: string;
|
||||
Actor: {
|
||||
ID: string;
|
||||
Attributes: Record<string, string>;
|
||||
};
|
||||
time: number;
|
||||
timeNano: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old entries from the deduplication cache
|
||||
* Also enforces max size limit with LRU eviction
|
||||
*/
|
||||
function cleanupRecentEvents() {
|
||||
const now = Date.now();
|
||||
// First pass: remove expired entries
|
||||
for (const [key, timestamp] of recentEvents.entries()) {
|
||||
if (now - timestamp > DEDUP_WINDOW_MS) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
// Second pass: enforce max size with LRU eviction if still too large
|
||||
if (recentEvents.size > MAX_DEDUP_CACHE_SIZE) {
|
||||
const entries = Array.from(recentEvents.entries())
|
||||
.sort((a, b) => a[1] - b[1]); // Sort by timestamp (oldest first)
|
||||
const toRemove = entries.slice(0, entries.length - MAX_DEDUP_CACHE_SIZE);
|
||||
for (const [key] of toRemove) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Docker event
|
||||
*/
|
||||
function processEvent(event: DockerEvent, envId: number) {
|
||||
// Only process container events
|
||||
if (event.Type !== 'container') return;
|
||||
|
||||
// Map Docker action to our action type
|
||||
// For health_status events, Docker sends "health_status: unhealthy" or "health_status: healthy"
|
||||
// We need to preserve the full string for notifications to distinguish healthy vs unhealthy
|
||||
const rawAction = event.Action;
|
||||
const baseAction = rawAction.split(':')[0] as ContainerEventAction;
|
||||
|
||||
// Skip actions we don't care about
|
||||
if (!CONTAINER_ACTIONS.includes(baseAction)) return;
|
||||
|
||||
// For notifications, preserve full action for health_status to enable proper mapping
|
||||
const action = rawAction.startsWith('health_status') ? rawAction : baseAction;
|
||||
|
||||
const containerId = event.Actor?.ID;
|
||||
const containerName = event.Actor?.Attributes?.name;
|
||||
const image = event.Actor?.Attributes?.image;
|
||||
|
||||
if (!containerId) return;
|
||||
|
||||
// Skip scanner containers (Trivy, Grype)
|
||||
if (isScannerContainer(image)) return;
|
||||
|
||||
// Skip internal Dockhand containers (volume browser helpers)
|
||||
if (isExcludedContainer(containerName)) return;
|
||||
|
||||
// Deduplicate events
|
||||
const dedupKey = `${envId}-${event.timeNano}-${containerId}-${action}`;
|
||||
if (recentEvents.has(dedupKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
recentEvents.set(dedupKey, Date.now());
|
||||
|
||||
// Clean up if cache gets too large
|
||||
if (recentEvents.size > 200) {
|
||||
cleanupRecentEvents();
|
||||
}
|
||||
|
||||
// Convert Unix nanosecond timestamp to ISO string
|
||||
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
|
||||
|
||||
// Prepare notification data
|
||||
// For health_status events, create a cleaner label
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
// Send event to main process for DB save and SSE broadcast
|
||||
send({
|
||||
type: 'container_event',
|
||||
event: {
|
||||
environmentId: envId,
|
||||
containerId: containerId,
|
||||
containerName: containerName || null,
|
||||
image: image || null,
|
||||
action,
|
||||
actorAttributes: event.Actor?.Attributes || null,
|
||||
timestamp
|
||||
},
|
||||
notification: {
|
||||
action,
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
notificationType,
|
||||
image
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll events for a specific environment (polling mode)
|
||||
*/
|
||||
async function pollEnvironmentEvents(envId: number, envName: string) {
|
||||
try {
|
||||
// Calculate 'since' timestamp (use last poll time, or start from 30s ago if first poll)
|
||||
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
|
||||
const since = lastPollTime.get(envId) || (now - 30); // Default to 30s ago on first poll
|
||||
|
||||
// Fetch events since last check until now
|
||||
// IMPORTANT: 'until' is required for polling mode, otherwise Docker keeps the connection open
|
||||
const eventStream = await getDockerEvents(
|
||||
{ type: ['container'] },
|
||||
envId,
|
||||
{ since: since.toString(), until: now.toString() }
|
||||
);
|
||||
|
||||
if (!eventStream) {
|
||||
console.error(`[EventSubprocess] Failed to fetch events for ${envName}`);
|
||||
updateEnvironmentStatus(envId, envName, false, 'Failed to fetch Docker events');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark environment as online
|
||||
updateEnvironmentStatus(envId, envName, true);
|
||||
|
||||
// Read and process all events
|
||||
const reader = eventStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as DockerEvent;
|
||||
processEvent(event, envId);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed
|
||||
}
|
||||
}
|
||||
|
||||
// Update last poll time
|
||||
lastPollTime.set(envId, now);
|
||||
|
||||
} catch (error: any) {
|
||||
if (!isShuttingDown) {
|
||||
console.error(`[EventSubprocess] Poll error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start collecting events for a specific environment
|
||||
*/
|
||||
async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
// Stop existing collector if any
|
||||
stopEnvironmentCollector(envId);
|
||||
|
||||
const controller = new AbortController();
|
||||
const entry = { controller, reconnectTimeout: null as ReturnType<typeof setTimeout> | null };
|
||||
collectors.set(envId, entry);
|
||||
|
||||
let reconnectDelay = RECONNECT_DELAY;
|
||||
|
||||
const connect = async () => {
|
||||
if (controller.signal.aborted || isShuttingDown) return;
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[EventSubprocess] Connecting to Docker events for ${envName} (env ${envId})...`
|
||||
);
|
||||
|
||||
const eventStream = await getDockerEvents({ type: ['container'] }, envId);
|
||||
|
||||
if (!eventStream) {
|
||||
console.error(`[EventSubprocess] Failed to get event stream for ${envName}`);
|
||||
updateEnvironmentStatus(envId, envName, false, 'Failed to connect to Docker');
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset reconnect delay on successful connection
|
||||
reconnectDelay = RECONNECT_DELAY;
|
||||
console.log(`[EventSubprocess] Connected to Docker events for ${envName}`);
|
||||
|
||||
updateEnvironmentStatus(envId, envName, true);
|
||||
|
||||
reader = eventStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (!controller.signal.aborted && !isShuttingDown) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as DockerEvent;
|
||||
processEvent(event, envId);
|
||||
} catch {
|
||||
// Ignore parse errors for partial chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(`[EventSubprocess] Stream error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection closed, reconnect
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted && !isShuttingDown && error.name !== 'AbortError') {
|
||||
console.error(`[EventSubprocess] Connection error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (controller.signal.aborted || isShuttingDown) return;
|
||||
|
||||
console.log(`[EventSubprocess] Reconnecting to ${envName} in ${reconnectDelay / 1000}s...`);
|
||||
entry.reconnectTimeout = setTimeout(() => {
|
||||
entry.reconnectTimeout = null;
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
connect();
|
||||
}
|
||||
}, reconnectDelay);
|
||||
|
||||
// Exponential backoff
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
// Start the connection
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling mode for a specific environment
|
||||
*/
|
||||
async function startEnvironmentPoller(envId: number, envName: string, interval: number) {
|
||||
// Stop existing poller if any
|
||||
stopEnvironmentPoller(envId);
|
||||
|
||||
console.log(`[EventSubprocess] Starting poller for ${envName} (every ${interval / 1000}s)`);
|
||||
|
||||
// Initial poll immediately
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
|
||||
// Set up interval for subsequent polls
|
||||
const intervalId = setInterval(async () => {
|
||||
if (!isShuttingDown) {
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
pollIntervals.set(envId, intervalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for a specific environment
|
||||
*/
|
||||
function stopEnvironmentPoller(envId: number) {
|
||||
const intervalId = pollIntervals.get(envId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
pollIntervals.delete(envId);
|
||||
lastPollTime.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop collecting events for a specific environment (streaming mode)
|
||||
*/
|
||||
function stopEnvironmentCollector(envId: number) {
|
||||
const entry = collectors.get(envId);
|
||||
if (entry) {
|
||||
if (entry.reconnectTimeout !== null) {
|
||||
clearTimeout(entry.reconnectTimeout);
|
||||
}
|
||||
entry.controller.abort();
|
||||
collectors.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh collectors when environments change
|
||||
*/
|
||||
async function refreshEventCollectors() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
const mode = await getEventCollectionMode();
|
||||
const pollInterval = await getEventPollInterval();
|
||||
|
||||
// Detect if settings changed
|
||||
const modeChanged = mode !== currentMode;
|
||||
const intervalChanged = pollInterval !== currentPollInterval;
|
||||
|
||||
if (modeChanged) {
|
||||
console.log(`[EventSubprocess] Mode changed from ${currentMode} to ${mode}`);
|
||||
currentMode = mode;
|
||||
}
|
||||
if (intervalChanged) {
|
||||
console.log(`[EventSubprocess] Poll interval changed from ${currentPollInterval}ms to ${pollInterval}ms`);
|
||||
currentPollInterval = pollInterval;
|
||||
}
|
||||
|
||||
// Filter: only collect for environments with activity enabled AND not Hawser Edge
|
||||
const activeEnvIds = new Set(
|
||||
environments
|
||||
.filter((e) => e.collectActivity && e.connectionType !== 'hawser-edge')
|
||||
.map((e) => e.id)
|
||||
);
|
||||
|
||||
// Stop collectors for removed environments or those with collection disabled
|
||||
for (const envId of collectors.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop pollers for removed environments or those with collection disabled
|
||||
// Also restart all pollers if interval changed
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping poller for environment ${envId}`);
|
||||
stopEnvironmentPoller(envId);
|
||||
} else if (intervalChanged && mode === 'poll') {
|
||||
// Restart poller with new interval
|
||||
console.log(`[EventSubprocess] Restarting poller for environment ${envId} with new interval`);
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale map entries for deleted environments
|
||||
const allEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of environmentOnlineStatus.keys()) {
|
||||
if (!allEnvIds.has(envId)) environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
for (const envId of lastPollTime.keys()) {
|
||||
if (!allEnvIds.has(envId)) lastPollTime.delete(envId);
|
||||
}
|
||||
|
||||
// Start collectors based on mode
|
||||
for (const env of environments) {
|
||||
// Skip Hawser Edge (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') continue;
|
||||
|
||||
// Skip if activity collection is disabled
|
||||
if (!env.collectActivity) continue;
|
||||
|
||||
const hasStreamCollector = collectors.has(env.id);
|
||||
const hasPoller = pollIntervals.has(env.id);
|
||||
|
||||
if (mode === 'stream') {
|
||||
// Switch from polling to streaming if needed
|
||||
if (hasPoller) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from poll to stream`);
|
||||
stopEnvironmentPoller(env.id);
|
||||
}
|
||||
// Start stream if not already running
|
||||
if (!hasStreamCollector) {
|
||||
startEnvironmentCollector(env.id, env.name);
|
||||
}
|
||||
} else if (mode === 'poll') {
|
||||
// Switch from streaming to polling if needed
|
||||
if (hasStreamCollector) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from stream to poll`);
|
||||
stopEnvironmentCollector(env.id);
|
||||
}
|
||||
// Start poller if not already running (will also restart after interval change above)
|
||||
if (!hasPoller) {
|
||||
startEnvironmentPoller(env.id, env.name, pollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[EventSubprocess] Failed to refresh collectors: ${message}`);
|
||||
send({ type: 'error', message: `Failed to refresh collectors: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle commands from main process
|
||||
*/
|
||||
function handleCommand(command: MainProcessCommand): void {
|
||||
switch (command.type) {
|
||||
case 'refresh_environments':
|
||||
console.log('[EventSubprocess] Refreshing environments...');
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
// This is used by metrics subprocess, but we handle it here too for consistency
|
||||
// Event subprocess re-reads interval from DB on refresh
|
||||
console.log('[EventSubprocess] Interval update - refreshing collectors...');
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[EventSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
function shutdown(): void {
|
||||
isShuttingDown = true;
|
||||
|
||||
// Stop periodic cache cleanup
|
||||
if (cacheCleanupInterval) {
|
||||
clearInterval(cacheCleanupInterval);
|
||||
cacheCleanupInterval = null;
|
||||
}
|
||||
|
||||
// Stop all environment stream collectors
|
||||
for (const envId of collectors.keys()) {
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
|
||||
// Stop all environment pollers
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
|
||||
// Clear the deduplication cache
|
||||
recentEvents.clear();
|
||||
|
||||
console.log('[EventSubprocess] Stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the event collector
|
||||
*/
|
||||
async function start(): Promise<void> {
|
||||
console.log('[EventSubprocess] Starting container event collection...');
|
||||
|
||||
// Initialize current settings from database
|
||||
try {
|
||||
currentMode = await getEventCollectionMode();
|
||||
currentPollInterval = await getEventPollInterval();
|
||||
console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[EventSubprocess] Failed to load settings, using defaults: ${message}`);
|
||||
}
|
||||
|
||||
// Start collectors for all environments
|
||||
await refreshEventCollectors();
|
||||
|
||||
// Start periodic cache cleanup
|
||||
cacheCleanupInterval = setInterval(cleanupRecentEvents, CACHE_CLEANUP_INTERVAL_MS);
|
||||
console.log('[EventSubprocess] Started deduplication cache cleanup (every 5s)');
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[EventSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB, ` +
|
||||
`dedup=${recentEvents.size}, collectors=${collectors.size}, pollers=${pollIntervals.size}`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
handleCommand(message);
|
||||
});
|
||||
|
||||
// Handle termination signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Signal ready
|
||||
send({ type: 'ready' });
|
||||
|
||||
console.log('[EventSubprocess] Started successfully');
|
||||
}
|
||||
|
||||
// Start the subprocess
|
||||
start();
|
||||
@@ -1,498 +0,0 @@
|
||||
/**
|
||||
* Metrics Collection Subprocess
|
||||
*
|
||||
* Runs as a separate process via Bun.spawn to collect CPU/memory metrics
|
||||
* and check disk space without blocking the main HTTP thread.
|
||||
*
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
|
||||
import os from 'node:os';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
let COLLECT_INTERVAL = 30000; // 30 seconds (default, will be loaded from settings)
|
||||
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
|
||||
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
|
||||
const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics
|
||||
const ENV_DISK_TIMEOUT = 20000; // 20 seconds timeout per environment for disk checks
|
||||
|
||||
/**
|
||||
* Timeout wrapper - returns fallback if promise takes too long
|
||||
* IMPORTANT: Properly clears the timeout to prevent memory leaks
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track last disk warning sent per environment to avoid spamming
|
||||
const lastDiskWarning: Map<number, number> = new Map();
|
||||
const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings
|
||||
|
||||
let collectInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
let collectionCycleCount = 0;
|
||||
const MEMORY_LOG_INTERVAL = 10; // Log memory every 10 cycles (~5 minutes at 30s interval)
|
||||
|
||||
/**
|
||||
* Send message to main process
|
||||
*/
|
||||
function send(message: any): void {
|
||||
if (process.send) {
|
||||
process.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics for a single environment
|
||||
*/
|
||||
async function collectEnvMetrics(env: { id: number; name: string; host?: string; socketPath?: string; collectMetrics?: boolean; connectionType?: string }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Hawser Edge environments (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get running containers
|
||||
const containers = await listContainers(false, env.id); // Only running
|
||||
let totalCpuPercent = 0;
|
||||
let totalContainerMemUsed = 0;
|
||||
|
||||
// Get stats for each running container
|
||||
const statsPromises = containers.map(async (container) => {
|
||||
try {
|
||||
const stats = (await getContainerStats(container.id, env.id)) as any;
|
||||
|
||||
// Calculate CPU percentage
|
||||
const cpuDelta =
|
||||
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta =
|
||||
stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length;
|
||||
|
||||
let cpuPercent = 0;
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
// Get container memory usage using the same formula as Docker CLI
|
||||
// Docker subtracts cache (inactive_file) from total usage
|
||||
// - cgroup v2: uses 'inactive_file'
|
||||
// - cgroup v1: uses 'total_inactive_file'
|
||||
const memUsage = stats.memory_stats?.usage || 0;
|
||||
const memStats = stats.memory_stats?.stats || {};
|
||||
const memCache = memStats.inactive_file ?? memStats.total_inactive_file ?? 0;
|
||||
const actualMemUsed = memCache > 0 && memCache < memUsage ? memUsage - memCache : memUsage;
|
||||
|
||||
return { cpuPercent, memUsage: actualMemUsed };
|
||||
} catch {
|
||||
return { cpuPercent: 0, memUsage: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
totalCpuPercent = statsResults.reduce((sum, r) => sum + r.cpuPercent, 0);
|
||||
totalContainerMemUsed = statsResults.reduce((sum, r) => sum + r.memUsage, 0);
|
||||
|
||||
// Get host memory info from Docker
|
||||
const info = (await getDockerInfo(env.id)) as any;
|
||||
const memTotal = info?.MemTotal || os.totalmem();
|
||||
|
||||
// Calculate memory: sum of all container memory vs host total
|
||||
const memUsed = totalContainerMemUsed;
|
||||
const memPercent = memTotal > 0 ? (memUsed / memTotal) * 100 : 0;
|
||||
|
||||
// Normalize CPU by number of cores from the Docker host
|
||||
const cpuCount = info?.NCPU || os.cpus().length;
|
||||
const normalizedCpu = totalCpuPercent / cpuCount;
|
||||
|
||||
// Validate values - skip if any are NaN, Infinity, or negative
|
||||
const finalCpu = Number.isFinite(normalizedCpu) && normalizedCpu >= 0 ? normalizedCpu : 0;
|
||||
const finalMemPercent = Number.isFinite(memPercent) && memPercent >= 0 ? memPercent : 0;
|
||||
const finalMemUsed = Number.isFinite(memUsed) && memUsed >= 0 ? memUsed : 0;
|
||||
const finalMemTotal = Number.isFinite(memTotal) && memTotal > 0 ? memTotal : 0;
|
||||
|
||||
// Only send if we have valid memory total (otherwise metrics are meaningless)
|
||||
if (finalMemTotal > 0) {
|
||||
send({
|
||||
type: 'metric',
|
||||
envId: env.id,
|
||||
cpu: finalCpu,
|
||||
memPercent: finalMemPercent,
|
||||
memUsed: finalMemUsed,
|
||||
memTotal: finalMemTotal
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails (might be offline)
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[MetricsSubprocess] Failed to collect metrics for ${env.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics for all environments
|
||||
*/
|
||||
async function collectMetrics() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and collect metrics in parallel
|
||||
const enabledEnvs = environments.filter((env) => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel with per-environment timeouts
|
||||
// Use Promise.allSettled so one slow/failed env doesn't block others
|
||||
const results = await Promise.allSettled(
|
||||
enabledEnvs.map((env) =>
|
||||
withTimeout(
|
||||
collectEnvMetrics(env).then(() => env.name),
|
||||
ENV_METRICS_TIMEOUT,
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Log any environments that timed out
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value === null) {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`);
|
||||
} else if (result.status === 'rejected') {
|
||||
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic memory logging for diagnostics
|
||||
collectionCycleCount++;
|
||||
if (collectionCycleCount % MEMORY_LOG_INTERVAL === 0) {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const rssMB = Math.round(memUsage.rss / 1024 / 1024);
|
||||
console.log(`[MetricsSubprocess] Memory: heap=${heapMB}MB, rss=${rssMB}MB (cycle ${collectionCycleCount})`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Metrics collection error: ${message}`);
|
||||
send({ type: 'error', message: `Metrics collection error: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse size string like "107.4GB" to bytes
|
||||
*/
|
||||
function parseSize(sizeStr: string): number {
|
||||
const units: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
TB: 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
return value * (units[unit] || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let size = bytes;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for a single environment
|
||||
*/
|
||||
async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean; connectionType?: string }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Hawser Edge environments (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if disk warnings are enabled for this environment
|
||||
const diskWarningEnabled = (await getEnvSetting('disk_warning_enabled', env.id)) ?? true;
|
||||
if (!diskWarningEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in cooldown for this environment
|
||||
const lastWarningTime = lastDiskWarning.get(env.id);
|
||||
if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) {
|
||||
return; // Skip this environment, still in cooldown
|
||||
}
|
||||
|
||||
// Get Docker disk usage data
|
||||
const diskData = (await getDiskUsage(env.id)) as any;
|
||||
if (!diskData) return;
|
||||
|
||||
// Calculate total Docker disk usage using reduce for cleaner code
|
||||
let totalUsed = 0;
|
||||
if (diskData.Images) {
|
||||
totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0);
|
||||
}
|
||||
if (diskData.Containers) {
|
||||
totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0);
|
||||
}
|
||||
if (diskData.Volumes) {
|
||||
totalUsed += diskData.Volumes.reduce(
|
||||
(sum: number, v: any) => sum + (v.UsageData?.Size || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
if (diskData.BuildCache) {
|
||||
totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0);
|
||||
}
|
||||
|
||||
// Get Docker root filesystem info from Docker info
|
||||
const info = (await getDockerInfo(env.id)) as any;
|
||||
const driverStatus = info?.DriverStatus;
|
||||
|
||||
// Try to find "Data Space Total" from driver status
|
||||
let dataSpaceTotal = 0;
|
||||
let diskPercentUsed = 0;
|
||||
|
||||
if (driverStatus) {
|
||||
for (const [key, value] of driverStatus) {
|
||||
if (key === 'Data Space Total' && typeof value === 'string') {
|
||||
dataSpaceTotal = parseSize(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine warning mode
|
||||
const diskWarningMode = (await getEnvSetting('disk_warning_mode', env.id)) ?? 'percentage';
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
if (diskWarningMode === 'absolute') {
|
||||
// Absolute mode: warn when usage exceeds GB threshold
|
||||
const thresholdGb = (await getEnvSetting('disk_warning_threshold_gb', env.id)) ?? 50;
|
||||
if (totalUsed > thresholdGb * GB) {
|
||||
send({
|
||||
type: 'disk_warning',
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space (threshold: ${thresholdGb} GB)`
|
||||
});
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
} else {
|
||||
// Percentage mode: need total disk space
|
||||
if (dataSpaceTotal > 0) {
|
||||
diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
|
||||
} else {
|
||||
// Can't determine percentage without total space — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
(await getEnvSetting('disk_warning_threshold', env.id)) || DEFAULT_DISK_THRESHOLD;
|
||||
if (diskPercentUsed >= threshold) {
|
||||
console.log(
|
||||
`[MetricsSubprocess] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`
|
||||
);
|
||||
|
||||
send({
|
||||
type: 'disk_warning',
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
|
||||
diskPercent: diskPercentUsed
|
||||
});
|
||||
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[MetricsSubprocess] Failed to check disk space for ${env.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for all environments
|
||||
*/
|
||||
async function checkDiskSpace() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and check disk space in parallel
|
||||
const enabledEnvs = environments.filter((env) => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel with per-environment timeouts
|
||||
// Use Promise.allSettled so one slow/failed env doesn't block others
|
||||
const results = await Promise.allSettled(
|
||||
enabledEnvs.map((env) =>
|
||||
withTimeout(
|
||||
checkEnvDiskSpace(env).then(() => env.name),
|
||||
ENV_DISK_TIMEOUT,
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Log any environments that timed out
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value === null) {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`);
|
||||
} else if (result.status === 'rejected') {
|
||||
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale lastDiskWarning entries for deleted environments
|
||||
const activeEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of lastDiskWarning.keys()) {
|
||||
if (!activeEnvIds.has(envId)) lastDiskWarning.delete(envId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Disk space check error: ${message}`);
|
||||
send({ type: 'error', message: `Disk space check error: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle commands from main process
|
||||
*/
|
||||
function handleCommand(command: MainProcessCommand): void {
|
||||
switch (command.type) {
|
||||
case 'refresh_environments':
|
||||
console.log('[MetricsSubprocess] Refreshing environments...');
|
||||
// The next collection cycle will pick up the new environments
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
console.log(`[MetricsSubprocess] Updating collection interval to ${command.intervalMs}ms`);
|
||||
COLLECT_INTERVAL = command.intervalMs;
|
||||
// Clear existing interval and restart with new timing
|
||||
if (collectInterval) {
|
||||
clearInterval(collectInterval);
|
||||
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[MetricsSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
function shutdown(): void {
|
||||
isShuttingDown = true;
|
||||
|
||||
if (collectInterval) {
|
||||
clearInterval(collectInterval);
|
||||
collectInterval = null;
|
||||
}
|
||||
if (diskCheckInterval) {
|
||||
clearInterval(diskCheckInterval);
|
||||
diskCheckInterval = null;
|
||||
}
|
||||
|
||||
lastDiskWarning.clear();
|
||||
console.log('[MetricsSubprocess] Stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collector
|
||||
*/
|
||||
async function start(): Promise<void> {
|
||||
// Load interval from settings
|
||||
try {
|
||||
COLLECT_INTERVAL = await getMetricsCollectionInterval();
|
||||
console.log(`[MetricsSubprocess] Starting metrics collection (every ${COLLECT_INTERVAL / 1000}s)...`);
|
||||
} catch (error) {
|
||||
console.error('[MetricsSubprocess] Failed to load interval from settings, using default 30s');
|
||||
COLLECT_INTERVAL = 30000;
|
||||
}
|
||||
|
||||
// Initial collection
|
||||
collectMetrics();
|
||||
|
||||
// Schedule regular collection
|
||||
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
|
||||
// Start disk space checking (every 5 minutes) - can be disabled for Synology NAS
|
||||
const skipDfCollection = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
if (!skipDfCollection) {
|
||||
console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)');
|
||||
checkDiskSpace(); // Initial check
|
||||
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
|
||||
} else {
|
||||
console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)');
|
||||
}
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[MetricsSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
handleCommand(message);
|
||||
});
|
||||
|
||||
// Handle termination signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Signal ready
|
||||
send({ type: 'ready' });
|
||||
|
||||
console.log('[MetricsSubprocess] Started successfully');
|
||||
}
|
||||
|
||||
// Start the subprocess
|
||||
start();
|
||||
@@ -0,0 +1,346 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { ContainerInfo, ContainerStats } from '$lib/types';
|
||||
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export interface AutoUpdateSetting {
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
vulnerabilityCriteria?: string;
|
||||
}
|
||||
|
||||
export interface ContainerStoreState {
|
||||
/** Container list */
|
||||
data: ContainerInfo[];
|
||||
/** Live stats keyed by container ID */
|
||||
stats: Map<string, ContainerStats>;
|
||||
/** Previous stats snapshot for change detection */
|
||||
previousStats: Map<string, ContainerStats>;
|
||||
/** Auto-update settings keyed by container name */
|
||||
autoUpdateSettings: Map<string, AutoUpdateSetting>;
|
||||
/** Container IDs with pending updates */
|
||||
pendingUpdateIds: string[];
|
||||
/** Container names for pending updates, keyed by ID */
|
||||
pendingUpdateNames: Map<string, string>;
|
||||
/** Whether the current environment has vulnerability scanning */
|
||||
envHasScanning: boolean;
|
||||
/** Environment-level vulnerability criteria */
|
||||
envVulnerabilityCriteria: 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current';
|
||||
/** True during initial load (no cached data for this env) */
|
||||
loading: boolean;
|
||||
/** The environment ID this data belongs to */
|
||||
envId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ContainerStoreState = {
|
||||
data: [],
|
||||
stats: new Map(),
|
||||
previousStats: new Map(),
|
||||
autoUpdateSettings: new Map(),
|
||||
pendingUpdateIds: [],
|
||||
pendingUpdateNames: new Map(),
|
||||
envHasScanning: false,
|
||||
envVulnerabilityCriteria: 'never',
|
||||
loading: true,
|
||||
envId: null
|
||||
};
|
||||
|
||||
function createContainerStore() {
|
||||
const { subscribe, set, update } = writable<ContainerStoreState>({ ...INITIAL_STATE });
|
||||
|
||||
// In-flight request tracking to avoid duplicate concurrent fetches
|
||||
let fetchingContainers = false;
|
||||
let fetchingStats = false;
|
||||
|
||||
function patch(partial: Partial<ContainerStoreState>) {
|
||||
update((s) => ({ ...s, ...partial }));
|
||||
}
|
||||
|
||||
function formatSchedule(
|
||||
scheduleType: string,
|
||||
cronExpression: string
|
||||
): { label: string; tooltip: string } {
|
||||
if (!cronExpression) return { label: 'on', tooltip: 'Auto-update enabled' };
|
||||
|
||||
const parts = cronExpression.split(' ');
|
||||
if (parts.length < 5) return { label: 'cron', tooltip: cronExpression };
|
||||
|
||||
const [min, hr, , , dow] = parts;
|
||||
const hourNum = parseInt(hr);
|
||||
const minNum = parseInt(min);
|
||||
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
|
||||
|
||||
if (scheduleType === 'daily' || dow === '*') {
|
||||
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
|
||||
}
|
||||
|
||||
if (scheduleType === 'weekly') {
|
||||
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
const dayName = days[parseInt(dow)] || dow;
|
||||
return {
|
||||
label: dayName,
|
||||
tooltip: `Every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][parseInt(dow)] || dow} at ${timeStr}`
|
||||
};
|
||||
}
|
||||
|
||||
return { label: 'cron', tooltip: cronExpression };
|
||||
}
|
||||
|
||||
async function checkScannerSettings(envId: number | null) {
|
||||
if (!envId) {
|
||||
patch({ envHasScanning: false, envVulnerabilityCriteria: 'never' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [scannerResponse, updateCheckResponse] = await Promise.all([
|
||||
fetch(`/api/settings/scanner?env=${envId}&settingsOnly=true`),
|
||||
fetch(`/api/environments/${envId}/update-check`)
|
||||
]);
|
||||
|
||||
let envHasScanning = false;
|
||||
let envVulnerabilityCriteria: ContainerStoreState['envVulnerabilityCriteria'] = 'never';
|
||||
|
||||
if (scannerResponse.ok) {
|
||||
const data = await scannerResponse.json();
|
||||
const settings = data.settings || data;
|
||||
envHasScanning = settings.scanner !== 'none';
|
||||
}
|
||||
|
||||
if (updateCheckResponse.ok) {
|
||||
const data = await updateCheckResponse.json();
|
||||
envVulnerabilityCriteria = data.settings?.vulnerabilityCriteria || 'never';
|
||||
}
|
||||
|
||||
patch({ envHasScanning, envVulnerabilityCriteria });
|
||||
} catch {
|
||||
patch({ envHasScanning: false, envVulnerabilityCriteria: 'never' });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAutoUpdateSettings(envId: number | null) {
|
||||
const settings = new Map<string, AutoUpdateSetting>();
|
||||
const envParam = envId ? `?env=${envId}` : '';
|
||||
|
||||
await checkScannerSettings(envId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auto-update${envParam}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
for (const [containerName, setting] of Object.entries(data)) {
|
||||
if (
|
||||
setting &&
|
||||
typeof setting === 'object' &&
|
||||
'enabled' in setting &&
|
||||
(setting as any).enabled
|
||||
) {
|
||||
const s = setting as {
|
||||
enabled: boolean;
|
||||
scheduleType: string;
|
||||
cronExpression: string | null;
|
||||
vulnerabilityCriteria: string;
|
||||
};
|
||||
const { label, tooltip } = formatSchedule(
|
||||
s.scheduleType,
|
||||
s.cronExpression || ''
|
||||
);
|
||||
settings.set(containerName, {
|
||||
enabled: true,
|
||||
label,
|
||||
tooltip,
|
||||
vulnerabilityCriteria: s.vulnerabilityCriteria || 'never'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch auto-update settings:', err);
|
||||
}
|
||||
|
||||
patch({ autoUpdateSettings: settings });
|
||||
}
|
||||
|
||||
async function fetchContainersInternal(envId: number | null) {
|
||||
if (!browser || !envId || fetchingContainers) return;
|
||||
fetchingContainers = true;
|
||||
|
||||
const state = get({ subscribe });
|
||||
// Only show loading spinner if we have no cached data for this env
|
||||
const showLoading = state.data.length === 0 || state.envId !== envId;
|
||||
if (showLoading) {
|
||||
patch({ loading: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers', envId));
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 && envId) {
|
||||
clearStaleEnvironment(envId);
|
||||
environments.refresh();
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to load containers');
|
||||
return;
|
||||
}
|
||||
const data: ContainerInfo[] = await response.json();
|
||||
patch({ data, envId });
|
||||
|
||||
// Fetch auto-update settings after containers load
|
||||
await fetchAutoUpdateSettings(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch containers:', error);
|
||||
toast.error('Failed to load containers');
|
||||
} finally {
|
||||
patch({ loading: false });
|
||||
fetchingContainers = false;
|
||||
}
|
||||
}
|
||||
|
||||
let statsAbortController: AbortController | null = null;
|
||||
|
||||
async function fetchStatsInternal(envId: number | null) {
|
||||
if (!browser || !envId || fetchingStats) return;
|
||||
fetchingStats = true;
|
||||
|
||||
// Abort any previous in-flight stream
|
||||
statsAbortController?.abort();
|
||||
statsAbortController = new AbortController();
|
||||
|
||||
// Snapshot previous stats once at cycle start
|
||||
update((s) => ({ ...s, previousStats: new Map(s.stats) }));
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
appendEnvParam('/api/containers/stats/stream', envId),
|
||||
{ signal: statsAbortController.signal }
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let currentEvent = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(':')) continue;
|
||||
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
if (currentEvent === 'stat') {
|
||||
try {
|
||||
const stat: ContainerStats = JSON.parse(line.slice(6));
|
||||
// Merge into existing stats map
|
||||
update((s) => {
|
||||
const newStats = new Map(s.stats);
|
||||
newStats.set(stat.id, stat);
|
||||
return { ...s, stats: newStats };
|
||||
});
|
||||
} catch {
|
||||
// Skip malformed data
|
||||
}
|
||||
}
|
||||
// done/error events — just let the loop finish
|
||||
currentEvent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Failed to fetch container stats:', error);
|
||||
}
|
||||
} finally {
|
||||
fetchingStats = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingUpdatesInternal(envId: number | null) {
|
||||
if (!browser || !envId) return;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/pending-updates', envId));
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.pendingUpdates && data.pendingUpdates.length > 0) {
|
||||
patch({
|
||||
pendingUpdateIds: data.pendingUpdates.map((u: any) => u.containerId),
|
||||
pendingUpdateNames: new Map(
|
||||
data.pendingUpdates.map((u: any) => [u.containerId, u.containerName])
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - background load
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/** Full refresh: containers + auto-update settings */
|
||||
refreshContainers(envId: number | null) {
|
||||
return fetchContainersInternal(envId);
|
||||
},
|
||||
|
||||
/** Stats-only refresh (called on 5s interval) */
|
||||
refreshStats(envId: number | null) {
|
||||
return fetchStatsInternal(envId);
|
||||
},
|
||||
|
||||
/** Full refresh: containers + stats + settings + pending updates */
|
||||
async refresh(envId: number | null) {
|
||||
await Promise.all([
|
||||
fetchContainersInternal(envId),
|
||||
fetchStatsInternal(envId),
|
||||
loadPendingUpdatesInternal(envId)
|
||||
]);
|
||||
},
|
||||
|
||||
/** Reload pending updates from database */
|
||||
loadPendingUpdates(envId: number | null) {
|
||||
return loadPendingUpdatesInternal(envId);
|
||||
},
|
||||
|
||||
/** Clear all data (environment switch) */
|
||||
invalidate() {
|
||||
statsAbortController?.abort();
|
||||
fetchingStats = false;
|
||||
set({
|
||||
...INITIAL_STATE,
|
||||
loading: true
|
||||
});
|
||||
},
|
||||
|
||||
/** Clear data without loading state (no environment selected) */
|
||||
clear() {
|
||||
statsAbortController?.abort();
|
||||
fetchingStats = false;
|
||||
set({ ...INITIAL_STATE, loading: false });
|
||||
},
|
||||
|
||||
/** Update pending update IDs and names directly (from check-updates action) */
|
||||
setPendingUpdates(ids: string[], names: Map<string, string>) {
|
||||
patch({ pendingUpdateIds: ids, pendingUpdateNames: names });
|
||||
},
|
||||
|
||||
/** Patch arbitrary fields */
|
||||
patch
|
||||
};
|
||||
}
|
||||
|
||||
export const containerStore = createContainerStore();
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Clean PEM content by removing whitespace artifacts from copy/paste.
|
||||
* Bun's TLS is strict about PEM format - it fails when certificates have
|
||||
* TLS implementations are strict about PEM format - they fail when certificates have
|
||||
* leading/trailing spaces on lines or extra blank lines.
|
||||
*
|
||||
* @param pem - The PEM content to clean
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { JobLine } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Reads a job-based response (POST returns { jobId }) and polls until complete.
|
||||
* Drop-in replacement for readSSEResponse when the endpoint has been migrated to jobs.
|
||||
*
|
||||
* Returns the job's final result (equivalent to the 'result' event data in SSE).
|
||||
*/
|
||||
export async function readJobResponse(
|
||||
response: Response
|
||||
): Promise<{ success?: boolean; error?: string; [key: string]: unknown }> {
|
||||
// Fall through for non-JSON or error responses
|
||||
if (!response.ok) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return { success: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// If the response is a { jobId } shape, poll the job endpoint
|
||||
if (data && typeof data === 'object' && 'jobId' in data) {
|
||||
const result = await watchJob(data.jobId as string, () => {
|
||||
// readJobResponse callers don't need line-by-line updates
|
||||
});
|
||||
return result as { success?: boolean; error?: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
// Fallback: response was already the final result (e.g. application/json sync path)
|
||||
return data;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Polls /api/jobs/{jobId} every 500ms. Calls onLine for each new line as they arrive.
|
||||
* Resolves with the job's final result when status is 'done' or 'error'.
|
||||
*/
|
||||
export async function watchJob(
|
||||
jobId: string,
|
||||
onLine: (line: JobLine) => void
|
||||
): Promise<unknown> {
|
||||
let cursor = 0;
|
||||
|
||||
while (true) {
|
||||
const res = await fetch(`/api/jobs/${jobId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Job poll failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const job = await res.json() as {
|
||||
id: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
lines: JobLine[];
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
// Deliver new lines since last poll
|
||||
const newLines = job.lines.slice(cursor);
|
||||
cursor = job.lines.length;
|
||||
for (const line of newLines) {
|
||||
onLine(line);
|
||||
}
|
||||
|
||||
if (job.status !== 'running') {
|
||||
return job.result;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
@@ -920,6 +920,7 @@
|
||||
}
|
||||
unsubscribeDashboardData();
|
||||
unsubscribePrefs();
|
||||
mobileWatcher.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -521,16 +521,12 @@
|
||||
// Add to beginning of events (prepend new events) - use Set for fast duplicate check
|
||||
if (!eventIds.has(newEvent.id)) {
|
||||
eventIds.add(newEvent.id);
|
||||
// Use unshift() for in-place mutation instead of spread for O(n) copy
|
||||
events.unshift(newEvent);
|
||||
events = events; // Trigger Svelte reactivity
|
||||
events = [newEvent, ...events];
|
||||
total = total + 1;
|
||||
|
||||
// Add container to list if not already there
|
||||
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
|
||||
containers.push(newEvent.containerName);
|
||||
containers.sort();
|
||||
containers = containers; // Trigger Svelte reactivity
|
||||
containers = [...containers, newEvent.containerName].sort();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -624,6 +620,11 @@
|
||||
}).then(() => {
|
||||
connectSSE();
|
||||
initialLoadDone = true;
|
||||
}).catch((err) => {
|
||||
console.error('[Activity] Init chain failed:', err);
|
||||
// Connect SSE anyway so live events still work
|
||||
connectSSE();
|
||||
initialLoadDone = true;
|
||||
});
|
||||
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { containerEventEmitter } from '$lib/server/event-collector';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -52,6 +53,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
};
|
||||
|
||||
// Send initial connection event
|
||||
|
||||
sendEvent('connected', { timestamp: new Date().toISOString() });
|
||||
|
||||
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
|
||||
@@ -84,6 +86,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
},
|
||||
cancel() {
|
||||
// Cleanup when client disconnects
|
||||
|
||||
clearInterval(heartbeatInterval);
|
||||
if (handleEvent) {
|
||||
containerEventEmitter.off('event', handleEvent);
|
||||
|
||||
@@ -30,11 +30,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
// Rate limiting by IP and username
|
||||
const clientIp = getClientAddress();
|
||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| getClientAddress();
|
||||
const rateLimitKey = `${clientIp}:${username}`;
|
||||
|
||||
const { limited, retryAfter } = isRateLimited(rateLimitKey);
|
||||
if (limited) {
|
||||
console.warn(`[Auth] Login rate-limited: user=${username} ip=${clientIp} retryAfter=${retryAfter}s`);
|
||||
return json(
|
||||
{ error: `Too many login attempts. Please try again in ${retryAfter} seconds.` },
|
||||
{ status: 429 }
|
||||
@@ -66,6 +69,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
if (!result.success) {
|
||||
recordFailedAttempt(rateLimitKey);
|
||||
console.warn(`[Auth] Login failed: user=${username} provider=${authProviderType} ip=${clientIp} reason=${result.error || 'Authentication failed'}`);
|
||||
return json({ error: result.error || 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -80,12 +84,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user || !(await verifyMfaToken(user.id, mfaToken))) {
|
||||
recordFailedAttempt(rateLimitKey);
|
||||
console.warn(`[Auth] MFA failed: user=${username} ip=${clientIp}`);
|
||||
return json({ error: 'Invalid MFA code' }, { status: 401 });
|
||||
}
|
||||
|
||||
// MFA verified, create session
|
||||
const session = await createUserSession(user.id, authProviderType, cookies);
|
||||
const session = await createUserSession(user.id, authProviderType, cookies, request);
|
||||
clearRateLimit(rateLimitKey);
|
||||
console.log(`[Auth] Login successful: user=${username} provider=${authProviderType} ip=${clientIp} mfa=yes`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', user.username, {
|
||||
@@ -107,8 +113,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
// No MFA, create session directly
|
||||
if (result.user) {
|
||||
const session = await createUserSession(result.user.id, authProviderType, cookies);
|
||||
const session = await createUserSession(result.user.id, authProviderType, cookies, request);
|
||||
clearRateLimit(rateLimitKey);
|
||||
console.log(`[Auth] Login successful: user=${result.user.username} provider=${authProviderType} ip=${clientIp} mfa=no`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
|
||||
@@ -11,8 +11,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Get current user before destroying session for audit log
|
||||
const auth = await authorize(cookies);
|
||||
const username = auth.user?.username || 'unknown';
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
|
||||
await destroySession(cookies);
|
||||
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'logout', username);
|
||||
|
||||
@@ -17,9 +17,15 @@ export const GET: RequestHandler = async (event) => {
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Extract client IP for logging
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
|
||||
// Handle error from IdP
|
||||
if (error) {
|
||||
console.error('OIDC error from IdP:', error, errorDescription);
|
||||
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${errorDescription || error}`);
|
||||
const errorMsg = encodeURIComponent(errorDescription || error);
|
||||
throw redirect(302, `/login?error=${errorMsg}`);
|
||||
}
|
||||
@@ -33,12 +39,14 @@ export const GET: RequestHandler = async (event) => {
|
||||
const result = await handleOidcCallback(code, state);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${result.error || 'Authentication failed'}`);
|
||||
const errorMsg = encodeURIComponent(result.error || 'Authentication failed');
|
||||
throw redirect(302, `/login?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
// Create session
|
||||
await createUserSession(result.user.id, 'oidc', cookies);
|
||||
await createUserSession(result.user.id, 'oidc', cookies, event.request);
|
||||
console.log(`[Auth] OIDC login successful: user=${result.user.username} provider=${result.providerName || 'oidc'} ip=${clientIp}`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
|
||||
+57
-109
@@ -8,8 +8,6 @@ import {
|
||||
pauseContainer,
|
||||
unpauseContainer,
|
||||
removeContainer,
|
||||
inspectContainer,
|
||||
listContainers,
|
||||
removeImage,
|
||||
removeVolume,
|
||||
removeNetwork
|
||||
@@ -23,6 +21,8 @@ import {
|
||||
} from '$lib/server/stacks';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { prefersJSON } from '$lib/server/sse';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
// SSE Event types
|
||||
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
|
||||
@@ -105,15 +105,13 @@ interface BatchRequest {
|
||||
async function processWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
processor: (item: T, index: number) => Promise<void>,
|
||||
signal: AbortSignal
|
||||
processor: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
let currentIndex = 0;
|
||||
const total = items.length;
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
while (currentIndex < total) {
|
||||
if (signal.aborted) return;
|
||||
const index = currentIndex++;
|
||||
await processor(items[index], index);
|
||||
}
|
||||
@@ -128,7 +126,7 @@ async function processWithConcurrency<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified batch operations endpoint with SSE streaming.
|
||||
* Unified batch operations endpoint (job pattern).
|
||||
* Handles bulk operations for containers, images, volumes, networks, and stacks.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
@@ -182,124 +180,74 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
// Check if audit is needed (enterprise only)
|
||||
const needsAudit = auth.isEnterprise;
|
||||
|
||||
// Create abort controller for cancellation
|
||||
const abortController = new AbortController();
|
||||
// Sync path for API clients that prefer plain JSON (Accept: application/json only)
|
||||
if (prefersJSON(request)) {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
await processWithConcurrency(items, 3, async (item, index) => {
|
||||
const { id, name } = item;
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: BatchEvent) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
};
|
||||
return json({
|
||||
type: 'complete',
|
||||
summary: { total: items.length, success: successCount, failed: failCount }
|
||||
});
|
||||
}
|
||||
|
||||
// Send SSE keepalive comments every 5s
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
}
|
||||
}, 5000);
|
||||
// Job pattern: create job, process in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
(async () => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Send start event
|
||||
safeEnqueue({
|
||||
type: 'start',
|
||||
total: items.length
|
||||
appendLine(job, { data: { type: 'start', total: items.length } });
|
||||
|
||||
await processWithConcurrency(items, 3, async (item, index) => {
|
||||
const { id, name } = item;
|
||||
|
||||
appendLine(job, {
|
||||
data: { type: 'progress', id, name, status: 'processing', current: index + 1, total: items.length }
|
||||
});
|
||||
|
||||
// Process items with concurrency of 3
|
||||
await processWithConcurrency(
|
||||
items,
|
||||
3,
|
||||
async (item, index) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const { id, name } = item;
|
||||
|
||||
// Send processing status
|
||||
safeEnqueue({
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
appendLine(job, {
|
||||
data: { type: 'progress', id, name, status: 'success', current: index + 1, total: items.length }
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
appendLine(job, {
|
||||
data: {
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'processing',
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'success',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
total: items.length,
|
||||
success: successCount,
|
||||
failed: failCount
|
||||
}
|
||||
});
|
||||
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
const completeEvent = {
|
||||
type: 'complete',
|
||||
summary: { total: items.length, success: successCount, failed: failCount }
|
||||
};
|
||||
appendLine(job, { data: completeEvent });
|
||||
completeJob(job, completeEvent);
|
||||
})().catch((err) => failJob(job, err.message));
|
||||
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, DockerConnectionError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -40,7 +40,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing containers:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Error listing containers:', error);
|
||||
}
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -50,9 +51,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let extension = '.tar';
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
body = gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -56,6 +58,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
|
||||
return { type: 'hawser-edge', environmentId: envId };
|
||||
}
|
||||
const protocol = (env.protocol as 'http' | 'https') || 'http';
|
||||
|
||||
return {
|
||||
type: protocol,
|
||||
host: env.host || 'localhost',
|
||||
@@ -119,6 +122,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -226,6 +230,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
cancelStream = cancel;
|
||||
},
|
||||
cancel() {
|
||||
|
||||
controllerClosed = true;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
@@ -279,28 +284,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = { headers: inspectHeaders };
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders });
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
@@ -322,6 +315,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -343,32 +337,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let response: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
response = await fetch(`http://localhost${logsPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath,
|
||||
signal: abortController?.signal
|
||||
});
|
||||
response = await unixSocketStreamRequest(config.socketPath, logsPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
response = await fetch(logsUrl, fetchOpts);
|
||||
response = await fetch(logsUrl, { headers: logsHeaders });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -434,7 +412,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
} catch (error) {
|
||||
if (!controllerClosed) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -444,7 +423,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on normal stream end (not just cancel)
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
@@ -453,7 +439,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
export interface ScanResult {
|
||||
critical: number;
|
||||
@@ -96,423 +97,123 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'containerIds array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: UpdateProgress) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
const sendData = (data: UpdateProgress) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
|
||||
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}, 5000);
|
||||
(async () => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
// Get scanner settings for this environment
|
||||
const scannerSettings = await getScannerSettings(envIdNum);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
|
||||
const shouldScan = scannerSettings.scanner !== 'none';
|
||||
|
||||
// Get scanner settings for this environment
|
||||
const scannerSettings = await getScannerSettings(envIdNum);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
|
||||
const shouldScan = scannerSettings.scanner !== 'none';
|
||||
// Send start event
|
||||
sendData({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
|
||||
// Send start event
|
||||
safeEnqueue({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
// Process containers sequentially
|
||||
for (let i = 0; i < containerIds.length; i++) {
|
||||
const containerId = containerIds[i];
|
||||
let containerName = 'unknown';
|
||||
|
||||
// Process containers sequentially
|
||||
for (let i = 0; i < containerIds.length; i++) {
|
||||
const containerId = containerIds[i];
|
||||
let containerName = 'unknown';
|
||||
try {
|
||||
// Find container
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
try {
|
||||
// Find container
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
if (!container) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
containerName = container.name;
|
||||
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const wasRunning = inspectData.State.Running;
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 1: Pull latest image
|
||||
safeEnqueue({
|
||||
if (!container) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
if (data.status) {
|
||||
safeEnqueue({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
containerName = container.name;
|
||||
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const config = inspectData.Config;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Get new image ID
|
||||
const newImageId = await getImageIdByTag(imageName, envIdNum);
|
||||
if (!newImageId) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Failed to get new image ID after pull'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore original tag to old image (safety)
|
||||
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
|
||||
try {
|
||||
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
|
||||
} catch {
|
||||
// Ignore - old image might have been removed
|
||||
}
|
||||
|
||||
// Tag new image with temp suffix
|
||||
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
|
||||
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
|
||||
|
||||
// Step 2: Scan temp image
|
||||
safeEnqueue({
|
||||
type: 'scan_start',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'scanning',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Scanning ${imageName} for vulnerabilities...`
|
||||
});
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
||||
if (progress.output || progress.message) {
|
||||
safeEnqueue({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || progress.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
high: result.summary.high,
|
||||
medium: result.summary.medium,
|
||||
low: result.summary.low,
|
||||
negligible: result.summary.negligible,
|
||||
unknown: result.summary.unknown
|
||||
}));
|
||||
|
||||
// Save scan results
|
||||
for (const result of scanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envIdNum,
|
||||
imageId: newImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
safeEnqueue({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
} catch (scanErr: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Scan failed: ${scanErr.message}`
|
||||
});
|
||||
|
||||
// Clean up temp image on scan failure
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scanBlocked) {
|
||||
// BLOCKED - Remove temp image and skip this container
|
||||
safeEnqueue({
|
||||
type: 'blocked',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'blocked',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
});
|
||||
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
blockedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// APPROVED - Re-tag to original
|
||||
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
|
||||
try {
|
||||
await removeTempImage(tempTag, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
safeEnqueue({
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (updateSuccess) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
successCount++;
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
// Step 1: Pull latest image
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
});
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
if (data.status) {
|
||||
sendData({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
@@ -520,53 +221,310 @@ export const POST: RequestHandler = async (event) => {
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
|
||||
// Get new image ID
|
||||
const newImageId = await getImageIdByTag(imageName, envIdNum);
|
||||
if (!newImageId) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Failed to get new image ID after pull'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore original tag to old image (safety)
|
||||
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
|
||||
try {
|
||||
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
|
||||
} catch {
|
||||
// Ignore - old image might have been removed
|
||||
}
|
||||
|
||||
// Tag new image with temp suffix
|
||||
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
|
||||
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
|
||||
|
||||
// Step 2: Scan temp image
|
||||
sendData({
|
||||
type: 'scan_start',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'scanning',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Scanning ${imageName} for vulnerabilities...`
|
||||
});
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
||||
if (progress.output || progress.message) {
|
||||
sendData({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || progress.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
high: result.summary.high,
|
||||
medium: result.summary.medium,
|
||||
low: result.summary.low,
|
||||
negligible: result.summary.negligible,
|
||||
unknown: result.summary.unknown
|
||||
}));
|
||||
|
||||
// Save scan results
|
||||
for (const result of scanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envIdNum,
|
||||
imageId: newImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
sendData({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
} catch (scanErr: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Scan failed: ${scanErr.message}`
|
||||
});
|
||||
|
||||
// Clean up temp image on scan failure
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scanBlocked) {
|
||||
// BLOCKED - Remove temp image and skip this container
|
||||
sendData({
|
||||
type: 'blocked',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'blocked',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
});
|
||||
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
blockedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// APPROVED - Re-tag to original
|
||||
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
|
||||
try {
|
||||
await removeTempImage(tempTag, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let newContainerId = containerId;
|
||||
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
blocked: blockedCount,
|
||||
skipped: skippedCount
|
||||
},
|
||||
message: skippedCount > 0 || blockedCount > 0
|
||||
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
});
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
} catch {
|
||||
// Controller already closed - ignore
|
||||
controllerClosed = true;
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
|
||||
if (!recreateResult.success) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
});
|
||||
successCount++;
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
const completeData: UpdateProgress = {
|
||||
type: 'complete',
|
||||
summary: {
|
||||
total: containerIds.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
blocked: blockedCount,
|
||||
skipped: skippedCount
|
||||
},
|
||||
message: skippedCount > 0 || blockedCount > 0
|
||||
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
};
|
||||
sendData(completeData);
|
||||
completeJob(job, completeData);
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
@@ -75,11 +75,10 @@ export const POST: RequestHandler = async (event) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum);
|
||||
if (updateSuccess) {
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
@@ -87,12 +86,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
if (!recreateResult.success) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Get all running containers with timeout
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
5000, // 5 second timeout
|
||||
10000, // 10 second timeout
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
@@ -127,7 +127,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
3000, // 3 second timeout per container
|
||||
8000, // 8 second timeout per container (TLS proxy + Docker CPU sampling needs ~2s)
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { ContainerStats } from '$lib/types';
|
||||
|
||||
function calculateCpuPercent(stats: any): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
|
||||
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
return (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
|
||||
if (stats.networks) {
|
||||
for (const iface of Object.values(stats.networks) as any[]) {
|
||||
rx += iface.rx_bytes || 0;
|
||||
tx += iface.tx_bytes || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { rx, tx };
|
||||
}
|
||||
|
||||
function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
|
||||
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
|
||||
if (Array.isArray(ioStats)) {
|
||||
for (const entry of ioStats) {
|
||||
if (entry.op === 'read' || entry.op === 'Read') {
|
||||
read += entry.value || 0;
|
||||
} else if (entry.op === 'write' || entry.op === 'Write') {
|
||||
write += entry.value || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!await hasEnvironments() || !envIdNum) {
|
||||
return new Response('event: done\ndata: {}\n\n', {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let controllerClosed = false;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
10000,
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
|
||||
const statsPromises = runningContainers.map(async (container) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
8000,
|
||||
null
|
||||
);
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
const stat: ContainerStats = {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
networkTx: networkIO.tx,
|
||||
blockRead: blockIO.read,
|
||||
blockWrite: blockIO.write
|
||||
};
|
||||
|
||||
safeEnqueue(`event: stat\ndata: ${JSON.stringify(stat)}\n\n`);
|
||||
} catch {
|
||||
// Skip failed containers silently
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statsPromises);
|
||||
} catch (error: any) {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'Environment not found' })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controllerClosed) {
|
||||
safeEnqueue(`event: done\ndata: {}\n\n`);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getEnvironments,
|
||||
getLatestHostMetrics,
|
||||
getHostMetrics,
|
||||
getMetricsCollectionInterval,
|
||||
getContainerEventStats,
|
||||
getContainerEvents,
|
||||
getEnvSetting,
|
||||
@@ -14,13 +15,17 @@ import {
|
||||
listImages,
|
||||
listNetworks,
|
||||
getContainerStats,
|
||||
getDiskUsage
|
||||
getDiskUsage,
|
||||
dockerPing,
|
||||
DockerConnectionError
|
||||
} from '$lib/server/docker';
|
||||
import { listComposeStacks } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
import type { EnvironmentStats } from '../+server';
|
||||
import { parseLabels } from '$lib/utils/label-colors';
|
||||
|
||||
|
||||
// Skip disk usage collection (Synology NAS performance fix)
|
||||
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
|
||||
@@ -68,6 +73,9 @@ setInterval(() => {
|
||||
}
|
||||
}, 10 * 60 * 1000); // Every 10 minutes
|
||||
|
||||
// Register cache reporter for memory monitoring
|
||||
|
||||
|
||||
async function getCachedDiskUsage(envId: number): Promise<any> {
|
||||
const cached = diskUsageCache.get(envId);
|
||||
const now = Date.now();
|
||||
@@ -125,10 +133,14 @@ function calculateMemoryUsage(memoryStats: any): number {
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Target time window for metrics history charts (15 minutes)
|
||||
const METRICS_HISTORY_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
// Progressive stats loading - returns stats object and emits partial updates via callback
|
||||
async function getEnvironmentStatsProgressive(
|
||||
env: any,
|
||||
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
|
||||
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void,
|
||||
metricsPointCount: number
|
||||
): Promise<EnvironmentStats> {
|
||||
const envStats: EnvironmentStats = {
|
||||
id: env.id,
|
||||
@@ -187,7 +199,7 @@ async function getEnvironmentStatsProgressive(
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }),
|
||||
getHostMetrics(30, env.id),
|
||||
getHostMetrics(metricsPointCount, env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
]);
|
||||
|
||||
@@ -237,6 +249,20 @@ async function getEnvironmentStatsProgressive(
|
||||
loading: { ...envStats.loading }
|
||||
});
|
||||
|
||||
// Quick reachability check — if ping fails, skip all expensive Docker API calls
|
||||
if (!await dockerPing(env.id)) {
|
||||
envStats.online = false;
|
||||
envStats.error = 'Environment offline';
|
||||
envStats.loading = undefined;
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: 'Environment offline',
|
||||
loading: undefined
|
||||
});
|
||||
return envStats;
|
||||
}
|
||||
|
||||
// Helper to get valid size
|
||||
const getValidSize = (size: number | undefined | null): number => {
|
||||
return size && size > 0 ? size : 0;
|
||||
@@ -511,7 +537,7 @@ async function getEnvironmentStatsProgressive(
|
||||
return envStats;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
export const GET: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
@@ -535,6 +561,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
let controllerClosed = false;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Safe enqueue that checks if controller is still open
|
||||
@@ -573,17 +600,23 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}));
|
||||
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
|
||||
|
||||
// Calculate metrics point count based on configured interval
|
||||
const metricsIntervalMs = await getMetricsCollectionInterval();
|
||||
const metricsPointCount = Math.ceil(METRICS_HISTORY_WINDOW_MS / metricsIntervalMs);
|
||||
|
||||
// Fetch stats for each environment with progressive updates
|
||||
const promises = environments.map(async (env) => {
|
||||
try {
|
||||
await getEnvironmentStatsProgressive(env, (partialStats) => {
|
||||
// Send partial update as it arrives
|
||||
safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`);
|
||||
});
|
||||
}, metricsPointCount);
|
||||
// Send final complete stats event for this environment
|
||||
safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get stats for ${env.name}:`, error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error(`Failed to get stats for ${env.name}:`, error);
|
||||
}
|
||||
// Convert technical error to user-friendly message
|
||||
const errorStr = String(error);
|
||||
let friendlyError = 'Connection error';
|
||||
@@ -611,19 +644,25 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Called when the client disconnects
|
||||
controllerClosed = true;
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
|
||||
if (prefersJSON(request)) return sseToJSON(sseResponse);
|
||||
return sseResponse;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Memory Debug Endpoint
|
||||
*
|
||||
* Returns Node.js memory stats for monitoring.
|
||||
* Only available when MEMORY_MONITOR=true environment variable is set.
|
||||
*
|
||||
* GET /api/debug/memory - Memory stats (with optional ?gc=true to force GC first)
|
||||
* GET /api/debug/memory?gc=true - Force garbage collection before reporting
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
||||
|
||||
// Track startup time and initial RSS for growth rate calculation
|
||||
const startupTime = Date.now();
|
||||
const startupRss = process.memoryUsage().rss;
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
if (process.env.MEMORY_MONITOR !== 'true') {
|
||||
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Trigger manual heap snapshot
|
||||
if (url.searchParams.has('snapshot')) {
|
||||
const filename = dumpHeapSnapshot();
|
||||
return json({
|
||||
snapshot: filename ? { filename, message: 'Heap snapshot saved' } : { error: 'Failed to save snapshot' }
|
||||
});
|
||||
}
|
||||
|
||||
// List saved snapshots
|
||||
if (url.searchParams.has('snapshots')) {
|
||||
return json({ snapshots: listHeapSnapshots() });
|
||||
}
|
||||
|
||||
// Force GC if requested and available
|
||||
const forceGc = url.searchParams.get('gc') === 'true';
|
||||
if (forceGc && typeof globalThis.gc === 'function') {
|
||||
globalThis.gc();
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const heap = v8.getHeapStatistics();
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowth = mem.rss - startupRss;
|
||||
const rssGrowthPerHour = uptimeHours > 0.01 ? rssGrowth / uptimeHours : 0;
|
||||
|
||||
return json({
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: {
|
||||
ms: uptimeMs,
|
||||
hours: Math.round(uptimeHours * 100) / 100,
|
||||
human: formatUptime(uptimeMs),
|
||||
},
|
||||
gcForced: forceGc && typeof globalThis.gc === 'function',
|
||||
gcAvailable: typeof globalThis.gc === 'function',
|
||||
process: {
|
||||
rss: formatBytes(mem.rss),
|
||||
heapTotal: formatBytes(mem.heapTotal),
|
||||
heapUsed: formatBytes(mem.heapUsed),
|
||||
external: formatBytes(mem.external),
|
||||
arrayBuffers: formatBytes(mem.arrayBuffers),
|
||||
rssRaw: mem.rss,
|
||||
heapTotalRaw: mem.heapTotal,
|
||||
heapUsedRaw: mem.heapUsed,
|
||||
externalRaw: mem.external,
|
||||
arrayBuffersRaw: mem.arrayBuffers,
|
||||
},
|
||||
growth: {
|
||||
rssSinceStartup: formatBytes(rssGrowth),
|
||||
rssPerHour: formatBytes(Math.round(rssGrowthPerHour)),
|
||||
startupRss: formatBytes(startupRss),
|
||||
},
|
||||
v8Heap: {
|
||||
totalHeapSize: formatBytes(heap.total_heap_size),
|
||||
usedHeapSize: formatBytes(heap.used_heap_size),
|
||||
heapSizeLimit: formatBytes(heap.heap_size_limit),
|
||||
totalPhysicalSize: formatBytes(heap.total_physical_size),
|
||||
totalAvailableSize: formatBytes(heap.total_available_size),
|
||||
mallocedMemory: formatBytes(heap.malloced_memory),
|
||||
peakMallocedMemory: formatBytes(heap.peak_malloced_memory),
|
||||
externalMemory: formatBytes(heap.external_memory),
|
||||
numberOfNativeContexts: heap.number_of_native_contexts,
|
||||
numberOfDetachedContexts: heap.number_of_detached_contexts,
|
||||
},
|
||||
system: {
|
||||
totalMemory: formatBytes(os.totalmem()),
|
||||
freeMemory: formatBytes(os.freemem()),
|
||||
cpus: os.cpus().length,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
nodeVersion: process.version,
|
||||
},
|
||||
rssTracker: getRssStats(),
|
||||
});
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sign = bytes < 0 ? '-' : '';
|
||||
const abs = Math.abs(bytes);
|
||||
if (abs < 1024) return `${sign}${abs} B`;
|
||||
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`;
|
||||
if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
await setEnvironmentPublicIp(env.id, data.publicIp);
|
||||
}
|
||||
|
||||
// Notify subprocesses to pick up the new environment
|
||||
// Notify event collectors to pick up the new environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
// Auto-assign Admin role to creator (Enterprise only)
|
||||
|
||||
@@ -91,7 +91,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Notify subprocesses if collectActivity or collectMetrics setting changed
|
||||
// Notify event collectors if collectActivity or collectMetrics setting changed
|
||||
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
|
||||
refreshSubprocessEnvironments();
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
await deleteImagePruneSettings(id);
|
||||
unregisterSchedule(id, 'image_prune');
|
||||
|
||||
// Notify subprocesses to stop collecting from deleted environment
|
||||
// Notify event collectors to stop collecting from deleted environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
// Audit log
|
||||
|
||||
@@ -71,7 +71,7 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
|
||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
||||
// (sequential calls can fail due to Bun TLS connection reuse issues)
|
||||
// (parallel calls are more efficient and avoid sequential connection issues)
|
||||
let info: any;
|
||||
let hawserInfo = null;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
|
||||
|
||||
/** Map of modern IANA timezone names to their canonical equivalents recognized by Bun/ICU */
|
||||
/** Map of modern IANA timezone names to their canonical equivalents recognized by ICU */
|
||||
const TIMEZONE_ALIASES: Record<string, string> = {
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { unixSocketRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface TestConnectionRequest {
|
||||
@@ -22,29 +24,19 @@ function cleanPem(pem: string): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildTlsOptions(config: TestConnectionRequest): Record<string, any> | undefined {
|
||||
function buildDockerClientConfig(config: TestConnectionRequest): DockerClientConfig | null {
|
||||
const protocol = config.protocol || 'http';
|
||||
if (protocol !== 'https') return undefined;
|
||||
if (protocol !== 'https') return null;
|
||||
|
||||
const tls: Record<string, any> = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host
|
||||
return {
|
||||
type: 'https',
|
||||
host: config.host || 'localhost',
|
||||
port: config.port || 2376,
|
||||
ca: config.tlsCa ? cleanPem(config.tlsCa) || undefined : undefined,
|
||||
cert: config.tlsCert ? cleanPem(config.tlsCert) || undefined : undefined,
|
||||
key: config.tlsKey ? cleanPem(config.tlsKey) || undefined : undefined,
|
||||
skipVerify: config.tlsSkipVerify || false
|
||||
};
|
||||
if (config.tlsSkipVerify) {
|
||||
tls.rejectUnauthorized = false;
|
||||
} else {
|
||||
tls.rejectUnauthorized = true;
|
||||
if (config.tlsCa) {
|
||||
tls.ca = [cleanPem(config.tlsCa)];
|
||||
}
|
||||
}
|
||||
if (config.tlsCert) {
|
||||
tls.cert = [cleanPem(config.tlsCert)];
|
||||
}
|
||||
if (config.tlsKey) {
|
||||
tls.key = cleanPem(config.tlsKey);
|
||||
}
|
||||
return tls;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +51,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
if (config.connectionType === 'socket') {
|
||||
const socketPath = config.socketPath || '/var/run/docker.sock';
|
||||
response = await fetch('http://localhost/info', {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: socketPath,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
response = await unixSocketRequest(socketPath, '/info');
|
||||
} else if (config.connectionType === 'hawser-edge') {
|
||||
// Edge mode - cannot test directly, agent connects to us
|
||||
return json({
|
||||
@@ -83,7 +71,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json({ success: false, error: 'Host is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = `${protocol}://${host}:${port}/info`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
@@ -92,19 +79,17 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
|
||||
const fetchOptions: any = {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
keepalive: false
|
||||
};
|
||||
|
||||
const tls = buildTlsOptions(config);
|
||||
if (tls) {
|
||||
fetchOptions.tls = tls;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
const tlsConfig = buildDockerClientConfig(config);
|
||||
if (tlsConfig) {
|
||||
response = await httpsAgentRequest(tlsConfig, '/info', {}, false, headers);
|
||||
} else {
|
||||
const url = `http://${host}:${port}/info`;
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
keepalive: false
|
||||
});
|
||||
}
|
||||
|
||||
response = await fetch(url, fetchOptions);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -123,21 +108,19 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
if (config.hawserToken) {
|
||||
hawserHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
const hawserUrl = `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`;
|
||||
|
||||
const fetchOptions: any = {
|
||||
headers: hawserHeaders,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
keepalive: false
|
||||
};
|
||||
|
||||
const tls = buildTlsOptions(config);
|
||||
if (tls) {
|
||||
fetchOptions.tls = tls;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
let hawserResp: Response;
|
||||
const tlsConfig = buildDockerClientConfig(config);
|
||||
if (tlsConfig) {
|
||||
hawserResp = await httpsAgentRequest(tlsConfig, '/_hawser/info', {}, false, hawserHeaders);
|
||||
} else {
|
||||
const hawserUrl = `http://${config.host}:${config.port || 2375}/_hawser/info`;
|
||||
hawserResp = await fetch(hawserUrl, {
|
||||
headers: hawserHeaders,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
keepalive: false
|
||||
});
|
||||
}
|
||||
|
||||
const hawserResp = await fetch(hawserUrl, fetchOptions);
|
||||
if (hawserResp.ok) {
|
||||
hawserInfo = await hawserResp.json();
|
||||
}
|
||||
@@ -182,6 +165,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
message = 'Connection failed - check host and port';
|
||||
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
|
||||
message = 'TLS certificate error - provide CA certificate for self-signed certs';
|
||||
} else if (rawMessage.includes('CERT_ALTNAME_INVALID') || rawMessage.includes('ERR_TLS_CERT_ALTNAME_INVALID')) {
|
||||
message = 'Certificate hostname mismatch - your certificate\'s Subject Alternative Name (SAN) doesn\'t match the host. Regenerate with: -addext "subjectAltName=DNS:hostname,IP:x.x.x.x"';
|
||||
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
|
||||
message = 'TLS/SSL error - check certificate configuration';
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import { secureRandomBytes } from '$lib/server/crypto-fallback';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
@@ -156,20 +157,33 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// If deployNow is set, deploy immediately
|
||||
// If deployNow is set, deploy immediately via SSE to keep connection alive
|
||||
if (data.deployNow) {
|
||||
const deployResult = await deployGitStack(gitStack.id);
|
||||
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||
return json({
|
||||
...gitStack,
|
||||
deployResult: deployResult
|
||||
});
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const deployResult = await deployGitStack(gitStack.id);
|
||||
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||
send('result', {
|
||||
...gitStack,
|
||||
deployResult: deployResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', {
|
||||
...gitStack,
|
||||
deployResult: { success: false, error: 'Failed to deploy git stack' }
|
||||
});
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
return json(gitStack);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create git stack:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('stack_environment_variables')) {
|
||||
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to create git stack' }, { status: 500 });
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars } from '$lib/server/db';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
@@ -131,20 +132,33 @@ export const PUT: RequestHandler = async (event) => {
|
||||
await setStackEnvVars(stackName, envId, varsToSave as any);
|
||||
}
|
||||
|
||||
// If deployNow is set, deploy after saving
|
||||
// If deployNow is set, deploy after saving via SSE to keep connection alive
|
||||
if (data.deployNow) {
|
||||
const deployResult = await deployGitStack(id);
|
||||
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
||||
return json({
|
||||
...updated,
|
||||
deployResult
|
||||
});
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const deployResult = await deployGitStack(id);
|
||||
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
||||
send('result', {
|
||||
...updated,
|
||||
deployResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', {
|
||||
...updated,
|
||||
deployResult: { success: false, error: 'Failed to deploy git stack' }
|
||||
});
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
return json(updated);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update git stack:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('stack_environment_variables')) {
|
||||
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to update git stack' }, { status: 500 });
|
||||
@@ -176,6 +190,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
// Delete the stack_sources record to free up the stack name
|
||||
await deleteStackSource(existing.stackName, existing.environmentId);
|
||||
|
||||
// Delete all env var overrides for this stack (all environments)
|
||||
await deleteStackEnvVars(existing.stackName);
|
||||
|
||||
// Delete from database
|
||||
await deleteGitStack(id);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { RequestHandler } from './$types';
|
||||
import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStackWithProgress } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
export const POST: RequestHandler = async ({ params, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const id = parseInt(params.id);
|
||||
@@ -25,30 +27,43 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sendEvent = (data: any) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
try {
|
||||
await deployGitStackWithProgress(id, sendEvent);
|
||||
} catch (error: any) {
|
||||
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
|
||||
} finally {
|
||||
controller.close();
|
||||
// Backward compat: API clients sending Accept: application/json get synchronous SSE result
|
||||
if (prefersJSON(request)) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendEvent = (data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
try {
|
||||
await deployGitStackWithProgress(id, sendEvent);
|
||||
} catch (error: any) {
|
||||
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
return sseToJSON(sseResponse);
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
// Job pattern: fire and forget, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
deployGitStackWithProgress(id, (data: unknown) => {
|
||||
appendLine(job, { data });
|
||||
})
|
||||
.then(() => {
|
||||
const lastLine = job.lines[job.lines.length - 1];
|
||||
const lastData = lastLine?.data as any;
|
||||
completeJob(job, lastData ?? { status: 'complete' });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
@@ -21,12 +22,19 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await deployGitStack(id);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployGitStack(id);
|
||||
|
||||
// Audit log
|
||||
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
||||
// Audit log
|
||||
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
||||
|
||||
return json(result);
|
||||
send('result', result);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', { success: false, error: 'Failed to deploy git stack' });
|
||||
}
|
||||
}, event.request);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
return json({ error: 'Failed to deploy git stack' }, { status: 500 });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Hawser Edge WebSocket Connect Endpoint
|
||||
*
|
||||
* This endpoint handles WebSocket connections from Hawser agents running in Edge mode.
|
||||
* In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174
|
||||
* In development: WebSocket is handled by ws.WebSocketServer in vite.config.ts on port 5174
|
||||
* In production: WebSocket is handled by the server wrapper in server.ts
|
||||
*
|
||||
* The HTTP GET endpoint returns connection info for clients.
|
||||
@@ -28,7 +28,7 @@ export const GET: RequestHandler = async () => {
|
||||
hostname: conn.hostname,
|
||||
capabilities: conn.capabilities,
|
||||
connectedAt: conn.connectedAt.toISOString(),
|
||||
lastHeartbeat: conn.lastHeartbeat.toISOString()
|
||||
lastHeartbeat: new Date(conn.lastHeartbeat).toISOString()
|
||||
}));
|
||||
|
||||
return json({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listImages, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { listImages, EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -32,7 +32,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing images:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Error listing images:', error);
|
||||
}
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pullImage } from '$lib/server/docker';
|
||||
import { pullImage, buildRegistryAuthHeader } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if environment is edge mode
|
||||
@@ -73,49 +74,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Check if this is an edge environment
|
||||
const edgeCheck = await isEdgeMode(envId);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let cancelEdgeStream: (() => void) | null = null;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (cancelEdgeStream) {
|
||||
cancelEdgeStream();
|
||||
cancelEdgeStream = null;
|
||||
}
|
||||
controllerClosed = true;
|
||||
const sendData = (data: unknown) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle scan-on-pull after image is pulled
|
||||
*/
|
||||
const handleScanOnPull = async () => {
|
||||
// Skip if caller explicitly requested no scan (e.g., CreateContainerModal handles scanning separately)
|
||||
if (skipScanOnPull) return;
|
||||
|
||||
const { scanner } = await getScannerSettings(envId);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
if (scanner !== 'none') {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'scanning', message: 'Starting vulnerability scan...' })}\n\n`);
|
||||
sendData({ status: 'scanning', message: 'Starting vulnerability scan...' });
|
||||
|
||||
try {
|
||||
const results = await scanImage(image, envId, (progress) => {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'scan-progress', ...progress })}\n\n`);
|
||||
sendData({ status: 'scan-progress', ...progress });
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
@@ -138,128 +116,99 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
sendData({
|
||||
status: 'scan-complete',
|
||||
message: `Scan complete - found ${totalVulns} vulnerabilities`,
|
||||
results
|
||||
})}\n\n`);
|
||||
});
|
||||
} catch (scanError) {
|
||||
console.error('Scan-on-pull failed:', scanError);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
sendData({
|
||||
status: 'scan-error',
|
||||
error: scanError instanceof Error ? scanError.message : String(scanError)
|
||||
})}\n\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
controller = ctrl;
|
||||
// Run operation in background
|
||||
(async () => {
|
||||
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
|
||||
|
||||
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
sendData({ status: 'error', error: 'Edge agent not connected' });
|
||||
failJob(job, 'Edge agent not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
|
||||
|
||||
// Handle edge mode with streaming
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const pullUrl = buildPullUrl(image);
|
||||
const pullUrl = buildPullUrl(image);
|
||||
const authHeaders = await buildRegistryAuthHeader(image);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId,
|
||||
edgeCheck.environmentId!,
|
||||
'POST',
|
||||
pullUrl,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
// Data is base64 encoded JSON lines from Docker
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
// Docker sends newline-delimited JSON
|
||||
const lines = decoded.split('\n').filter(line => line.trim());
|
||||
const lines = decoded.split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const progress = JSON.parse(line);
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
sendData(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore parse errors for partial lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If not base64, try as-is
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
sendData(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
onEnd: async () => {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
|
||||
|
||||
// Handle scan-on-pull
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
completeJob(job, { status: 'complete' });
|
||||
resolve();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge pull error:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
sendData({ status: 'error', error });
|
||||
failJob(job, error);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
authHeaders
|
||||
);
|
||||
|
||||
cancelEdgeStream = cancel;
|
||||
} else {
|
||||
// Non-edge mode: use existing pullImage function
|
||||
try {
|
||||
await pullImage(image, (progress) => {
|
||||
const data = JSON.stringify(progress) + '\n';
|
||||
safeEnqueue(`data: ${data}\n\n`);
|
||||
}, envId);
|
||||
// Store cancel reference (not used currently but available)
|
||||
void cancel;
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await pullImage(image, (progress) => {
|
||||
sendData(progress);
|
||||
}, envId);
|
||||
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
|
||||
|
||||
// Handle scan-on-pull
|
||||
await handleScanOnPull();
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.error('Error pulling image:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: String(error)
|
||||
})}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
completeJob(job, { status: 'complete' });
|
||||
} catch (error) {
|
||||
console.error('Error pulling image:', error);
|
||||
const errMsg = String(error);
|
||||
sendData({ status: 'error', error: errMsg });
|
||||
failJob(job, errMsg);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getRegistry, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { prefersJSON } from '$lib/server/sse';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if environment is edge mode
|
||||
@@ -119,35 +121,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Check if this is an edge environment
|
||||
const edgeCheck = await isEdgeMode(envIdNum);
|
||||
|
||||
// Stream the push progress
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let cancelEdgeStream: (() => void) | null = null;
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (cancelEdgeStream) {
|
||||
cancelEdgeStream();
|
||||
cancelEdgeStream = null;
|
||||
}
|
||||
controllerClosed = true;
|
||||
};
|
||||
|
||||
const formatError = (error: any): string => {
|
||||
const errorMessage = error.message || error || '';
|
||||
let userMessage = errorMessage || 'Failed to push image';
|
||||
@@ -163,140 +136,87 @@ export const POST: RequestHandler = async (event) => {
|
||||
return userMessage;
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
controller = ctrl;
|
||||
// Core push logic — emit callback receives progress data objects
|
||||
async function runPush(emit: (data: unknown) => void): Promise<void> {
|
||||
emit({ status: 'tagging', message: 'Tagging image...' });
|
||||
await tagImage(imageId, repo, tag, envIdNum);
|
||||
emit({ status: 'pushing', message: 'Pushing to registry...' });
|
||||
|
||||
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
emit({ status: 'error', error: 'Edge agent not connected' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send tagging status
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`);
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
|
||||
// Tag the image with the target registry
|
||||
await tagImage(imageId, repo, tag, envIdNum);
|
||||
|
||||
// Send pushing status
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'pushing', message: 'Pushing to registry...' })}\n\n`);
|
||||
|
||||
// Handle edge mode with streaming
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create X-Registry-Auth header
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId,
|
||||
'POST',
|
||||
`/images/${encodeURIComponent(targetTag)}/push`,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
// Data is base64 encoded JSON lines from Docker
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
const lines = decoded.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const progress = JSON.parse(line);
|
||||
if (progress.error) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
|
||||
} else {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for partial lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If not base64, try as-is
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId!,
|
||||
'POST',
|
||||
`/images/${encodeURIComponent(targetTag)}/push`,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
for (const line of decoded.split('\n').filter((l) => l.trim())) {
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
if (progress.error) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
|
||||
} else {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const progress = JSON.parse(line);
|
||||
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
|
||||
} catch { /* ignore partial lines */ }
|
||||
}
|
||||
},
|
||||
onEnd: async () => {
|
||||
// Audit log
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'complete',
|
||||
message: `Image pushed to ${targetTag}`,
|
||||
targetTag
|
||||
})}\n\n`);
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge push error:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(error) })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
} catch {
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ 'X-Registry-Auth': authHeader }
|
||||
);
|
||||
|
||||
cancelEdgeStream = cancel;
|
||||
} else {
|
||||
// Non-edge mode: use existing pushImage function
|
||||
await pushImage(targetTag, authConfig, (progress) => {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
|
||||
// Send completion message
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'complete',
|
||||
message: `Image pushed to ${targetTag}`,
|
||||
targetTag
|
||||
})}\n\n`);
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error pushing image:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: formatError(error)
|
||||
})}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
onEnd: async () => {
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
|
||||
resolve();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge push error:', error);
|
||||
emit({ status: 'error', error: formatError(error) });
|
||||
reject(new Error(error));
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ 'X-Registry-Auth': authHeader }
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await pushImage(targetTag, authConfig, (progress) => emit(progress), envIdNum);
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
// Sync path for API clients sending Accept: application/json only
|
||||
if (prefersJSON(request)) {
|
||||
try {
|
||||
let lastEvent: unknown = null;
|
||||
await runPush((data) => { lastEvent = data; });
|
||||
return json(lastEvent || { success: true });
|
||||
} catch (error: any) {
|
||||
return json({ status: 'error', error: formatError(error) }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Job pattern: return jobId immediately, push runs in background
|
||||
const job = createJob();
|
||||
(async () => {
|
||||
try {
|
||||
await runPush((data) => appendLine(job, { data }));
|
||||
completeJob(job, job.lines[job.lines.length - 1]?.data ?? { success: true });
|
||||
} catch (error: any) {
|
||||
appendLine(job, { data: { status: 'error', error: formatError(error) } });
|
||||
failJob(job, error.message);
|
||||
}
|
||||
})();
|
||||
return json({ jobId: job.id });
|
||||
} catch (error: any) {
|
||||
console.error('Error setting up push:', error);
|
||||
return json({ error: error.message || 'Failed to push image' }, { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
// Helper to convert ScanResult to database format
|
||||
function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
@@ -23,7 +24,7 @@ function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// POST - Start a scan (returns SSE stream for progress)
|
||||
// POST - Start a scan (returns { jobId } for progress polling)
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -42,75 +43,47 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
const data = `data: ${JSON.stringify(progress)}\n\n`;
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
appendLine(job, { data: progress });
|
||||
};
|
||||
|
||||
// Send SSE keepalive comments every 5s to prevent Traefik timeout
|
||||
const keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}, 5000);
|
||||
(async () => {
|
||||
try {
|
||||
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
|
||||
|
||||
try {
|
||||
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
|
||||
|
||||
// Save results to database
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
|
||||
}
|
||||
|
||||
// Send final complete message with all results
|
||||
sendProgress({
|
||||
stage: 'complete',
|
||||
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
|
||||
progress: 100,
|
||||
result: results[0],
|
||||
results: results // Include all scanner results
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
sendProgress({
|
||||
stage: 'error',
|
||||
message: `Scan failed: ${errorMsg}`,
|
||||
error: errorMsg
|
||||
});
|
||||
} finally {
|
||||
clearInterval(keepaliveInterval);
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
// Save results to database
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
|
||||
}
|
||||
|
||||
// Send final complete message with all results
|
||||
const completeProgress: ScanProgress = {
|
||||
stage: 'complete',
|
||||
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
|
||||
progress: 100,
|
||||
result: results[0],
|
||||
results: results // Include all scanner results
|
||||
};
|
||||
sendProgress(completeProgress);
|
||||
completeJob(job, completeProgress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorProgress: ScanProgress = {
|
||||
stage: 'error',
|
||||
message: `Scan failed: ${errorMsg}`,
|
||||
error: errorMsg
|
||||
};
|
||||
sendProgress(errorProgress);
|
||||
failJob(job, errorMsg);
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
// GET - Get cached scan results for an image
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/jobs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/jobs/[id]
|
||||
* Poll a job's status and accumulated lines.
|
||||
* Returns all lines every time — client tracks its own cursor locally.
|
||||
* No auth required: job IDs are UUIDs (unguessable), no sensitive data beyond what the initiating user triggered.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const job = getJob(params.id);
|
||||
if (!job) {
|
||||
return json({ error: 'Job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
lines: job.lines,
|
||||
result: job.result ?? null
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -424,37 +426,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
|
||||
// Build fetch options - only include tls for HTTPS
|
||||
const fetchOptions: any = {
|
||||
headers: inspectHeaders,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOptions.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||
if (config.key) fetchOptions.tls.key = config.key;
|
||||
fetchOptions.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
}
|
||||
|
||||
inspectResponse = await fetch(inspectUrl, fetchOptions);
|
||||
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders, signal: AbortSignal.timeout(30000) });
|
||||
}
|
||||
|
||||
if (!inspectResponse.ok) {
|
||||
await inspectResponse.arrayBuffer().catch(() => {});
|
||||
console.log(`[merged-logs] Inspect failed for ${containerId.slice(0, 12)}, skipping`);
|
||||
return null;
|
||||
}
|
||||
@@ -468,39 +453,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
let logsResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
logsResponse = await fetch(`http://localhost${logsPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath,
|
||||
signal: abortController.signal
|
||||
});
|
||||
logsResponse = await unixSocketStreamRequest(config.socketPath, logsPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
logsResponse = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
|
||||
// For logs streaming, use the cleanup abort controller without a timeout
|
||||
// (the stream needs to stay open indefinitely)
|
||||
const fetchOptions: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOptions.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||
if (config.key) fetchOptions.tls.key = config.key;
|
||||
fetchOptions.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
}
|
||||
|
||||
logsResponse = await fetch(logsUrl, fetchOptions);
|
||||
logsResponse = await fetch(logsUrl, { headers: logsHeaders, signal: abortController.signal });
|
||||
}
|
||||
|
||||
if (!logsResponse.ok) {
|
||||
await logsResponse.arrayBuffer().catch(() => {});
|
||||
console.error(`[merged-logs] Failed to get logs for container ${containerId}: ${logsResponse.status}`);
|
||||
return null;
|
||||
}
|
||||
@@ -647,7 +613,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
for (const source of sources) {
|
||||
if (source.reader) {
|
||||
try {
|
||||
source.reader.releaseLock();
|
||||
await source.reader.cancel().catch(() => {});
|
||||
source.reader.releaseLock();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getHostMetrics } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const limit = parseInt(url.searchParams.get('limit') || '60');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
const metrics = await getHostMetrics(limit, envIdNum);
|
||||
|
||||
// Return metrics in chronological order (oldest first) for graphing
|
||||
const chronological = metrics.reverse();
|
||||
|
||||
return json({
|
||||
metrics: chronological,
|
||||
latest: metrics.length > 0 ? metrics[metrics.length - 1] : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get host metrics:', error);
|
||||
return json({ error: 'Failed to get host metrics' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listNetworks, createNetwork, EnvironmentNotFoundError, type CreateNetworkOptions } from '$lib/server/docker';
|
||||
import { listNetworks, createNetwork, EnvironmentNotFoundError, DockerConnectionError, type CreateNetworkOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditNetwork } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to list networks:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Failed to list networks:', error);
|
||||
}
|
||||
return json({ error: 'Failed to list networks' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { pruneImages } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { audit } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -17,19 +18,21 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pruneImages(danglingOnly, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await pruneImages(danglingOnly, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await audit(event, 'prune', 'image', {
|
||||
environmentId: envIdNum,
|
||||
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
|
||||
details: { danglingOnly, result }
|
||||
});
|
||||
// Audit log
|
||||
await audit(event, 'prune', 'image', {
|
||||
environmentId: envIdNum,
|
||||
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
|
||||
details: { danglingOnly, result }
|
||||
});
|
||||
|
||||
return json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Error pruning images:', error);
|
||||
return json({ error: 'Failed to prune images' }, { status: 500 });
|
||||
}
|
||||
send('result', { success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Error pruning images:', error);
|
||||
send('result', { success: false, error: 'Failed to prune images' });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
|
||||
@@ -340,7 +340,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
|
||||
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
|
||||
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
|
||||
const UPDATER_LABEL = 'dockhand.updater';
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly.
|
||||
* Self-update always operates on the local engine — no environment routing needed.
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
/** Fetch from the local Docker socket (buffered). */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/** Fetch from the local Docker socket (streaming body for pull progress). */
|
||||
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +34,10 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
const response = await localDockerFetch(
|
||||
const authHeaders = await buildRegistryAuthHeader(imageName);
|
||||
const response = await localDockerStreamFetch(
|
||||
`/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`,
|
||||
{ method: 'POST' }
|
||||
{ method: 'POST', headers: authHeaders }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -400,11 +401,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
if (prefersJSON(request)) return sseToJSON(sseResponse);
|
||||
return sseResponse;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId } from '$lib/server/host-path';
|
||||
import { getRegistryManifestDigest } from '$lib/server/docker';
|
||||
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly (not through environment routing)
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly (not through environment routing) */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +74,92 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract tag from image name
|
||||
const colonIdx = currentImage.lastIndexOf(':');
|
||||
const tag = colonIdx > -1 ? currentImage.substring(colonIdx + 1) : 'latest';
|
||||
const imageWithoutTag = colonIdx > -1 ? currentImage.substring(0, colonIdx) : currentImage;
|
||||
|
||||
// Check if this is a versioned tag (e.g., v1.0.18, 1.0.18, v1.0.18-baseline)
|
||||
const versionMatch = tag.match(/^(v?\d+\.\d+\.\d+)(-baseline)?$/);
|
||||
|
||||
if (versionMatch) {
|
||||
// Version-based check: compare against latest released version from changelog
|
||||
const currentTagVersion = versionMatch[1];
|
||||
const suffix = versionMatch[2] || ''; // '-baseline' or ''
|
||||
|
||||
try {
|
||||
const changelogResponse = await fetch(
|
||||
'https://raw.githubusercontent.com/Finsys/dockhand/main/src/lib/data/changelog.json',
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (!changelogResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not fetch changelog from GitHub'
|
||||
});
|
||||
}
|
||||
|
||||
const changelog = await changelogResponse.json() as Array<{
|
||||
version: string;
|
||||
comingSoon?: boolean;
|
||||
date?: string;
|
||||
changes?: Array<{ type: string; text: string }>;
|
||||
}>;
|
||||
|
||||
// Find latest released version (first entry without comingSoon)
|
||||
const latestRelease = changelog.find(entry => !entry.comingSoon);
|
||||
|
||||
if (!latestRelease) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'No released version found in changelog'
|
||||
});
|
||||
}
|
||||
|
||||
const latestVersion = latestRelease.version;
|
||||
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
|
||||
|
||||
if (hasNewer) {
|
||||
// Build new image tag preserving registry prefix and suffix
|
||||
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
|
||||
const newImage = `${imageWithoutTag}:${newTag}`;
|
||||
|
||||
return json({
|
||||
updateAvailable: true,
|
||||
currentImage,
|
||||
newImage,
|
||||
latestVersion: latestVersion.replace(/^v/, ''),
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Version check failed: ' + String(err)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Digest-based check for mutable tags (:latest, :baseline, etc.)
|
||||
|
||||
// Inspect image via local Docker socket to get RepoDigests
|
||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||
if (!imageResponse.ok) {
|
||||
@@ -105,6 +187,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
isLocalImage: true
|
||||
@@ -117,6 +200,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not query registry'
|
||||
@@ -128,6 +212,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: hasUpdate,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
currentDigest: localDigests[0],
|
||||
newDigest: registryDigest,
|
||||
containerName,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { unixSocketRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly
|
||||
*/
|
||||
async function localDockerFetch(path: string): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly */
|
||||
function localDockerFetch(path: string): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
|
||||
import { EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
|
||||
import { upsertStackSource, getStackSources } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -62,8 +63,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
// Silently return empty for connection errors (offline environments)
|
||||
if (error instanceof DockerConnectionError) {
|
||||
return json([]);
|
||||
}
|
||||
console.error('Error listing compose stacks:', error);
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
};
|
||||
@@ -162,20 +166,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy and start the stack
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error, output: result.output }, { status: 400 });
|
||||
}
|
||||
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
// Record the stack in DB before deploying - ensures it exists even if deploy fails
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
@@ -184,10 +175,31 @@ export const POST: RequestHandler = async (event) => {
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
// Audit log (create + deploy in one action)
|
||||
await auditStack(event, 'deploy', name, envIdNum);
|
||||
// Deploy via SSE to keep connection alive during long operations
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: true, output: result.output });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error, output: result.output });
|
||||
return;
|
||||
}
|
||||
|
||||
// Audit log (create + deploy in one action)
|
||||
await auditStack(event, 'deploy', name, envIdNum);
|
||||
|
||||
send('result', { success: true, started: true, output: result.output });
|
||||
} catch (error: any) {
|
||||
console.error('Error deploying compose stack:', error);
|
||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||
}
|
||||
}, request);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating compose stack:', error);
|
||||
return json({ error: error.message || 'Failed to create stack' }, { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// GET /api/stacks/[name]/compose - Get compose file content
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -66,7 +67,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
||||
: undefined;
|
||||
|
||||
let result;
|
||||
if (restart) {
|
||||
// Deploy with docker compose up -d --force-recreate
|
||||
// Force recreate ensures env var changes are applied
|
||||
@@ -79,19 +79,34 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
}
|
||||
// Get authoritative paths from DB/filesystem for deploy
|
||||
const composeInfo = await getStackComposeFile(name, envIdNum);
|
||||
result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
envId: envIdNum,
|
||||
forceRecreate: true,
|
||||
composePath: composeInfo.composePath || undefined,
|
||||
envPath: composeInfo.envPath || undefined
|
||||
});
|
||||
} else {
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
|
||||
// Deploy via SSE to keep connection alive during long operations
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
envId: envIdNum,
|
||||
forceRecreate: true,
|
||||
composePath: composeInfo.composePath || undefined,
|
||||
envPath: composeInfo.envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true });
|
||||
} catch (error: any) {
|
||||
console.error(`Error deploying stack ${name}:`, error);
|
||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,31 +22,35 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Parse body BEFORE creating SSE response (body can only be read once)
|
||||
let removeVolumes = false;
|
||||
try {
|
||||
// Parse body for optional removeVolumes flag
|
||||
let removeVolumes = false;
|
||||
try {
|
||||
const body = await request.json();
|
||||
removeVolumes = body.removeVolumes === true;
|
||||
} catch {
|
||||
// No body or invalid JSON - use defaults
|
||||
}
|
||||
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await downStack(stackName, envIdNum, removeVolumes);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error downing compose stack:', error);
|
||||
return json({ error: 'Failed to down compose stack' }, { status: 500 });
|
||||
const body = await request.json();
|
||||
removeVolumes = body.removeVolumes === true;
|
||||
} catch {
|
||||
// No body or invalid JSON - use defaults
|
||||
}
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await downStack(stackName, envIdNum, removeVolumes);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
||||
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error downing compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to down compose stack' });
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
|
||||
import { findStackDir } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -94,7 +94,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
// Internal/adopted stacks: non-secrets from file, secrets from DB
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
const content = readFileSync(envFilePath, 'utf-8');
|
||||
const fileVars = parseEnvFile(content);
|
||||
for (const [key, value] of Object.entries(fileVars)) {
|
||||
variables.push({ key, value, isSecret: false });
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { findStackDir, getStackDir } from '$lib/server/stacks';
|
||||
import { getStackSource } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -58,7 +58,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let content = '';
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
content = await Bun.file(envFilePath).text();
|
||||
content = readFileSync(envFilePath, 'utf-8');
|
||||
} catch {
|
||||
// File read failed
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
await Bun.write(envFilePath, content);
|
||||
writeFileSync(envFilePath, content);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await restartStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await restartStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'restart', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'restart', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error restarting compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to restart compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error restarting compose stack:', error);
|
||||
return json({ error: 'Failed to restart compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await startStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await startStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'start', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'start', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error starting compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to start compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error starting compose stack:', error);
|
||||
return json({ error: 'Failed to start compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await stopStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await stopStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'stop', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'stop', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error stopping compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to stop compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error stopping compose stack:', error);
|
||||
return json({ error: 'Failed to stop compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
// Detect which stacks are already running on any environment
|
||||
const discoveredWithRunning = await detectRunningStacks(result.discovered);
|
||||
discoveredWithRunning.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return json({
|
||||
...result,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionIn
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
@@ -47,12 +48,12 @@ function detectContainerRuntime(): { inContainer: boolean; runtime?: string; con
|
||||
return { inContainer: false };
|
||||
}
|
||||
|
||||
// Get Bun runtime info
|
||||
function getBunInfo() {
|
||||
// Get runtime info
|
||||
function getRuntimeInfo() {
|
||||
const memUsage = process.memoryUsage();
|
||||
return {
|
||||
version: typeof Bun !== 'undefined' ? Bun.version : null,
|
||||
revision: typeof Bun !== 'undefined' ? Bun.revision?.slice(0, 7) : null,
|
||||
name: 'Node.js',
|
||||
version: process.version,
|
||||
memory: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
@@ -67,7 +68,6 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
|
||||
if (!containerId) return null;
|
||||
|
||||
try {
|
||||
// Try to inspect our own container via local socket
|
||||
const socketPaths = [
|
||||
'/var/run/docker.sock',
|
||||
process.env.DOCKER_SOCKET
|
||||
@@ -77,18 +77,33 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
|
||||
if (!socketPath || !existsSync(socketPath)) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: socketPath
|
||||
const info = await new Promise<any>((resolve, reject) => {
|
||||
const req = http.request({
|
||||
socketPath,
|
||||
path: `/containers/${containerId}/json`,
|
||||
method: 'GET',
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on('error', () => resolve(null));
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const info = await response.json();
|
||||
if (info) {
|
||||
return {
|
||||
id: info.Id?.slice(0, 12),
|
||||
name: info.Name?.replace(/^\//, ''),
|
||||
image: info.Config?.Image,
|
||||
imageId: info.Image?.slice(7, 19), // Remove 'sha256:' prefix
|
||||
imageId: info.Image?.slice(7, 19),
|
||||
created: info.Created,
|
||||
status: info.State?.Status,
|
||||
restartCount: info.RestartCount,
|
||||
@@ -153,7 +168,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const runningContainers = containers.filter(c => c.state === 'running').length;
|
||||
const stoppedContainers = containers.length - runningContainers;
|
||||
|
||||
const bunInfo = getBunInfo();
|
||||
const runtimeInfo = getRuntimeInfo();
|
||||
const containerRuntime = detectContainerRuntime();
|
||||
const ownContainer = containerRuntime.inContainer
|
||||
? await getOwnContainerInfo(containerRuntime.containerId || os.hostname())
|
||||
@@ -181,13 +196,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
storageDriver: dockerInfo.Driver
|
||||
} : null,
|
||||
runtime: {
|
||||
bun: bunInfo.version,
|
||||
bunRevision: bunInfo.revision,
|
||||
nodeVersion: process.version,
|
||||
runtimeName: runtimeInfo.name,
|
||||
runtimeVersion: runtimeInfo.version,
|
||||
nodeVersion: runtimeInfo.version,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
kernel: os.release(),
|
||||
memory: bunInfo.memory,
|
||||
memory: runtimeInfo.memory,
|
||||
container: containerRuntime,
|
||||
ownContainer
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Auto-login if this is the first user being created (and auth is enabled)
|
||||
let autoLoggedIn = false;
|
||||
if (isFirstUser && auth.authEnabled) {
|
||||
await createUserSession(user.id, 'local', cookies);
|
||||
await createUserSession(user.id, 'local', cookies, event.request);
|
||||
autoLoggedIn = true;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
|
||||
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505' || (error as any).cause?.code === '23505') {
|
||||
return json({ error: 'Username already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
|
||||
|
||||
@@ -229,7 +229,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update user:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('UNIQUE constraint failed') || (error as any).cause?.code === '23505') {
|
||||
return json({ error: 'Username already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: 'Failed to update user' }, { status: 500 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listVolumes, createVolume, EnvironmentNotFoundError, type CreateVolumeOptions } from '$lib/server/docker';
|
||||
import { listVolumes, createVolume, EnvironmentNotFoundError, DockerConnectionError, type CreateVolumeOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditVolume } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to list volumes:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Failed to list volumes:', error);
|
||||
}
|
||||
return json({ error: 'Failed to list volumes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVolumeArchive } from '$lib/server/docker';
|
||||
@@ -31,9 +32,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
body = gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
+142
-241
@@ -73,9 +73,10 @@
|
||||
import FileBrowserModal from './FileBrowserModal.svelte';
|
||||
import BatchUpdateModal from './BatchUpdateModal.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import type { ContainerInfo, ContainerStats } from '$lib/types';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
import { containerStore } from '$lib/stores/containers';
|
||||
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
@@ -87,8 +88,7 @@
|
||||
import type { ColumnConfig } from '$lib/types';
|
||||
import type { DataGridRowState } from '$lib/components/data-grid/types';
|
||||
|
||||
// Track previous stats for change detection
|
||||
let previousStats = $state<Map<string, ContainerStats>>(new Map());
|
||||
// Track change detection for stat highlighting (UI-only, stays in component)
|
||||
let changedFields = $state<Map<string, Set<string>>>(new Map());
|
||||
|
||||
// Format bytes to human readable
|
||||
@@ -100,21 +100,26 @@
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
|
||||
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory' | 'ports';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
let containers = $state<ContainerInfo[]>([]);
|
||||
let containerStats = $state<Map<string, ContainerStats>>(new Map());
|
||||
let autoUpdateSettings = $state<Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>>(new Map());
|
||||
// Data from persistent store (survives page navigation)
|
||||
const containers = $derived($containerStore.data);
|
||||
const containerStats = $derived($containerStore.stats);
|
||||
const autoUpdateSettings = $derived($containerStore.autoUpdateSettings);
|
||||
const envHasScanning = $derived($containerStore.envHasScanning);
|
||||
const envVulnerabilityCriteria = $derived($containerStore.envVulnerabilityCriteria);
|
||||
const loading = $derived($containerStore.loading);
|
||||
|
||||
let envId = $state<number | null>(null);
|
||||
let envHasScanning = $state(false);
|
||||
let envVulnerabilityCriteria = $state<'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'>('never');
|
||||
|
||||
// Derived: current environment details for reactive port URL generation
|
||||
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
|
||||
|
||||
// Search and sort state
|
||||
let searchQuery = $state('');
|
||||
// Search and sort state - initialize from URL for persistence across navigation
|
||||
const initialSearch = $page.url.searchParams.get('search')
|
||||
?? $page.url.searchParams.get('image') ?? '';
|
||||
let searchQuery = $state(initialSearch);
|
||||
let sortField = $state<SortField>('name');
|
||||
let sortDirection = $state<SortDirection>('asc');
|
||||
|
||||
@@ -167,6 +172,18 @@
|
||||
saveStatusFilter();
|
||||
});
|
||||
|
||||
// Sync search query to URL for persistence across navigation
|
||||
$effect(() => {
|
||||
const q = searchQuery;
|
||||
const url = new URL($page.url);
|
||||
if (q) url.searchParams.set('search', q);
|
||||
else url.searchParams.delete('search');
|
||||
url.searchParams.delete('image'); // clean up legacy param
|
||||
if (url.toString() !== $page.url.toString()) {
|
||||
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Track if initial fetch has been done
|
||||
let initialFetchDone = $state(false);
|
||||
|
||||
@@ -177,29 +194,28 @@
|
||||
|
||||
// Only fetch if environment actually changed or this is initial load
|
||||
if (env && (newEnvId !== envId || !initialFetchDone)) {
|
||||
const isEnvSwitch = envId !== null && newEnvId !== envId;
|
||||
envId = newEnvId;
|
||||
initialFetchDone = true;
|
||||
// Clear update state from previous environment
|
||||
batchUpdateContainerIds = [];
|
||||
batchUpdateContainerNames = new Map();
|
||||
updateCheckStatus = 'idle';
|
||||
// Clear shell detection cache for new environment
|
||||
shellDetectionCache = {};
|
||||
fetchContainers();
|
||||
fetchStats();
|
||||
loadPendingUpdates();
|
||||
|
||||
if (isEnvSwitch) {
|
||||
// Full env switch — invalidate cache, show spinner
|
||||
containerStore.invalidate();
|
||||
}
|
||||
// Refresh data (store handles loading state internally)
|
||||
containerStore.refresh(newEnvId);
|
||||
} else if (!env) {
|
||||
// No environment - clear data and stop loading
|
||||
envId = null;
|
||||
containers = [];
|
||||
loading = false;
|
||||
batchUpdateContainerIds = [];
|
||||
batchUpdateContainerNames = new Map();
|
||||
updateCheckStatus = 'idle';
|
||||
shellDetectionCache = {};
|
||||
containerStore.clear();
|
||||
}
|
||||
});
|
||||
let loading = $state(true);
|
||||
let showCreateModal = $state(false);
|
||||
let showEditModal = $state(false);
|
||||
let editContainerId = $state('');
|
||||
@@ -247,8 +263,8 @@
|
||||
// Update check state
|
||||
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
|
||||
let showBatchUpdateModal = $state(false);
|
||||
let batchUpdateContainerIds = $state<string[]>([]);
|
||||
let batchUpdateContainerNames = $state<Map<string, string>>(new Map());
|
||||
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
|
||||
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
|
||||
|
||||
// Single container update mode (doesn't overwrite batch list)
|
||||
let singleUpdateContainerId = $state<string | null>(null);
|
||||
@@ -332,7 +348,7 @@
|
||||
|
||||
function handleBatchOpComplete() {
|
||||
selectedContainers = new Set();
|
||||
fetchContainers();
|
||||
containerStore.refreshContainers(envId);
|
||||
}
|
||||
|
||||
function bulkStart() {
|
||||
@@ -353,9 +369,9 @@
|
||||
|
||||
function bulkRestart() {
|
||||
startBatchOperation(
|
||||
`Restarting ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
|
||||
`Restarting ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
|
||||
'restart',
|
||||
selectedInFilter
|
||||
selectedNonSystem
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,9 +393,9 @@
|
||||
|
||||
function bulkRemove() {
|
||||
startBatchOperation(
|
||||
`Removing ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
|
||||
`Removing ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
|
||||
'remove',
|
||||
selectedInFilter,
|
||||
selectedNonSystem,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
@@ -393,7 +409,7 @@
|
||||
});
|
||||
if (response.ok) {
|
||||
pruneStatus = 'success';
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} else {
|
||||
pruneStatus = 'error';
|
||||
}
|
||||
@@ -418,23 +434,28 @@
|
||||
}
|
||||
const data = await response.json();
|
||||
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
||||
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
|
||||
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
|
||||
|
||||
if (containersWithUpdates.length === 0) {
|
||||
updateCheckStatus = 'none';
|
||||
batchUpdateContainerIds = [];
|
||||
batchUpdateContainerNames.clear();
|
||||
toast.success('All containers are up to date');
|
||||
containerStore.setPendingUpdates([], new Map());
|
||||
if (failedChecks > 0) {
|
||||
toast.warning(`All containers are up to date${failedSuffix}`);
|
||||
} else {
|
||||
toast.success('All containers are up to date');
|
||||
}
|
||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data for batch update modal (but don't open it yet)
|
||||
batchUpdateContainerIds = containersWithUpdates.map((r: any) => r.containerId);
|
||||
batchUpdateContainerNames = new Map(
|
||||
containersWithUpdates.map((r: any) => [r.containerId, r.containerName])
|
||||
containerStore.setPendingUpdates(
|
||||
containersWithUpdates.map((r: any) => r.containerId),
|
||||
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
|
||||
);
|
||||
updateCheckStatus = 'found';
|
||||
toast.info(`${containersWithUpdates.length} update(s) available`);
|
||||
toast.info(`${containersWithUpdates.length} update(s) available${failedSuffix}`);
|
||||
} catch (error) {
|
||||
updateCheckStatus = 'error';
|
||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||
@@ -444,19 +465,10 @@
|
||||
// Load pending updates from database (persisted from check-updates or scheduled jobs)
|
||||
async function loadPendingUpdates() {
|
||||
if (!envId) return;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/pending-updates', envId));
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.pendingUpdates && data.pendingUpdates.length > 0) {
|
||||
batchUpdateContainerIds = data.pendingUpdates.map((u: any) => u.containerId);
|
||||
batchUpdateContainerNames = new Map(
|
||||
data.pendingUpdates.map((u: any) => [u.containerId, u.containerName])
|
||||
);
|
||||
updateCheckStatus = 'found';
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - this is a background load
|
||||
await containerStore.loadPendingUpdates(envId);
|
||||
// Update local UI status if there are pending updates
|
||||
if ($containerStore.pendingUpdateIds.length > 0) {
|
||||
updateCheckStatus = 'found';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,8 +487,7 @@
|
||||
selectedNames.set(id, container.name);
|
||||
}
|
||||
}
|
||||
batchUpdateContainerIds = selectedWithUpdates;
|
||||
batchUpdateContainerNames = selectedNames;
|
||||
containerStore.setPendingUpdates(selectedWithUpdates, selectedNames);
|
||||
showBatchUpdateModal = true;
|
||||
}
|
||||
|
||||
@@ -492,7 +503,7 @@
|
||||
}
|
||||
}
|
||||
if (allNames.size === 0) return;
|
||||
batchUpdateContainerNames = allNames;
|
||||
containerStore.patch({ pendingUpdateNames: allNames });
|
||||
showBatchUpdateModal = true;
|
||||
}
|
||||
|
||||
@@ -529,7 +540,7 @@
|
||||
// Reload pending updates from database to restore highlighting for remaining containers
|
||||
loadPendingUpdates();
|
||||
|
||||
fetchContainers();
|
||||
containerStore.refreshContainers(envId);
|
||||
}
|
||||
|
||||
// Action in progress state (for animations)
|
||||
@@ -717,6 +728,15 @@
|
||||
const stackB = b.labels?.['com.docker.compose.project'] || '';
|
||||
cmp = stackA.localeCompare(stackB);
|
||||
break;
|
||||
case 'ports':
|
||||
const getLowestPort = (c: ContainerInfo) => {
|
||||
const publicPorts = (c.ports || [])
|
||||
.filter((p: any) => p.PublicPort)
|
||||
.map((p: any) => p.PublicPort!);
|
||||
return publicPorts.length > 0 ? Math.min(...publicPorts) : Infinity;
|
||||
};
|
||||
cmp = getLowestPort(a) - getLowestPort(b);
|
||||
break;
|
||||
case 'ip':
|
||||
const ipA = getContainerIp(a.networks);
|
||||
const ipB = getContainerIp(b.networks);
|
||||
@@ -758,122 +778,15 @@
|
||||
filteredContainers.filter(c => selectedContainers.has(c.id))
|
||||
);
|
||||
|
||||
// Count by state for selected containers
|
||||
const selectedRunning = $derived(selectedInFilter.filter(c => c.state === 'running'));
|
||||
const selectedStopped = $derived(selectedInFilter.filter(c => c.state !== 'running' && c.state !== 'paused'));
|
||||
const selectedPaused = $derived(selectedInFilter.filter(c => c.state === 'paused'));
|
||||
// Count by state for selected containers (exclude system containers from destructive actions)
|
||||
const selectedNonSystem = $derived(selectedInFilter.filter(c => !c.systemContainer));
|
||||
const selectedRunning = $derived(selectedNonSystem.filter(c => c.state === 'running'));
|
||||
const selectedStopped = $derived(selectedNonSystem.filter(c => c.state !== 'running' && c.state !== 'paused'));
|
||||
const selectedPaused = $derived(selectedNonSystem.filter(c => c.state === 'paused'));
|
||||
|
||||
async function fetchContainers() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers', envId));
|
||||
if (!response.ok) {
|
||||
// Handle stale environment ID (e.g., after database reset)
|
||||
if (response.status === 404 && envId) {
|
||||
clearStaleEnvironment(envId);
|
||||
environments.refresh();
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to load containers');
|
||||
return;
|
||||
}
|
||||
containers = await response.json();
|
||||
// Fetch auto-update settings for all containers
|
||||
await fetchAutoUpdateSettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch containers:', error);
|
||||
toast.error('Failed to load containers');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkScannerSettings() {
|
||||
if (!envId) {
|
||||
envHasScanning = false;
|
||||
envVulnerabilityCriteria = 'never';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Fetch scanner settings and environment update-check settings in parallel
|
||||
const [scannerResponse, updateCheckResponse] = await Promise.all([
|
||||
fetch(`/api/settings/scanner?env=${envId}&settingsOnly=true`),
|
||||
fetch(`/api/environments/${envId}/update-check`)
|
||||
]);
|
||||
|
||||
if (scannerResponse.ok) {
|
||||
const data = await scannerResponse.json();
|
||||
const settings = data.settings || data;
|
||||
envHasScanning = settings.scanner !== 'none';
|
||||
}
|
||||
|
||||
if (updateCheckResponse.ok) {
|
||||
const data = await updateCheckResponse.json();
|
||||
envVulnerabilityCriteria = data.settings?.vulnerabilityCriteria || 'never';
|
||||
}
|
||||
} catch {
|
||||
envHasScanning = false;
|
||||
envVulnerabilityCriteria = 'never';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAutoUpdateSettings() {
|
||||
const settings = new Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>();
|
||||
const envParam = envId ? `?env=${envId}` : '';
|
||||
|
||||
// Check scanner settings first
|
||||
await checkScannerSettings();
|
||||
|
||||
// Fetch all auto-update settings in a single request
|
||||
try {
|
||||
const response = await fetch(`/api/auto-update${envParam}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// data is a map of containerName -> settings
|
||||
for (const [containerName, setting] of Object.entries(data)) {
|
||||
if (setting && typeof setting === 'object' && 'enabled' in setting && setting.enabled) {
|
||||
const s = setting as { enabled: boolean; scheduleType: string; cronExpression: string | null; vulnerabilityCriteria: string };
|
||||
const { label, tooltip } = formatSchedule(s.scheduleType, s.cronExpression || '');
|
||||
settings.set(containerName, {
|
||||
enabled: true,
|
||||
label,
|
||||
tooltip,
|
||||
vulnerabilityCriteria: s.vulnerabilityCriteria || 'never'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch auto-update settings:', err);
|
||||
}
|
||||
|
||||
autoUpdateSettings = settings;
|
||||
}
|
||||
|
||||
function formatSchedule(scheduleType: string, cronExpression: string): { label: string; tooltip: string } {
|
||||
if (!cronExpression) return { label: 'on', tooltip: 'Auto-update enabled' };
|
||||
|
||||
const parts = cronExpression.split(' ');
|
||||
if (parts.length < 5) return { label: 'cron', tooltip: cronExpression };
|
||||
|
||||
const [min, hr, , , dow] = parts;
|
||||
const hourNum = parseInt(hr);
|
||||
const minNum = parseInt(min);
|
||||
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
|
||||
|
||||
if (scheduleType === 'daily' || dow === '*') {
|
||||
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
|
||||
}
|
||||
|
||||
if (scheduleType === 'weekly') {
|
||||
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
const dayName = days[parseInt(dow)] || dow;
|
||||
return { label: dayName, tooltip: `Every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][parseInt(dow)] || dow} at ${timeStr}` };
|
||||
}
|
||||
|
||||
return { label: 'cron', tooltip: cronExpression };
|
||||
// Thin wrappers — delegate to persistent store
|
||||
function fetchContainers() {
|
||||
return containerStore.refreshContainers(envId);
|
||||
}
|
||||
|
||||
// Check if highlightChanges is enabled for current environment
|
||||
@@ -896,49 +809,37 @@
|
||||
return changedFields.get(containerId)?.has(field) ?? false;
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/stats', envId));
|
||||
const stats: ContainerStats[] = await response.json();
|
||||
const statsMap = new Map<string, ContainerStats>();
|
||||
const newChangedFields = new Map<string, Set<string>>();
|
||||
// Detect stat changes for highlighting when store stats update
|
||||
$effect(() => {
|
||||
const currentStats = $containerStore.stats;
|
||||
const prevStats = $containerStore.previousStats;
|
||||
|
||||
for (const stat of stats) {
|
||||
statsMap.set(stat.id, stat);
|
||||
if (!highlightChangesEnabled || prevStats.size === 0) return;
|
||||
|
||||
// Track changes if highlighting is enabled
|
||||
if (highlightChangesEnabled) {
|
||||
const prev = previousStats.get(stat.id);
|
||||
if (prev) {
|
||||
const changes = new Set<string>();
|
||||
if (hasFieldChanged(stat.id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
|
||||
if (hasFieldChanged(stat.id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
|
||||
if (hasFieldChanged(stat.id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
|
||||
if (hasFieldChanged(stat.id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
|
||||
if (hasFieldChanged(stat.id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
|
||||
if (hasFieldChanged(stat.id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
|
||||
if (changes.size > 0) {
|
||||
newChangedFields.set(stat.id, changes);
|
||||
}
|
||||
}
|
||||
const newChangedFields = new Map<string, Set<string>>();
|
||||
for (const [id, stat] of currentStats) {
|
||||
const prev = prevStats.get(id);
|
||||
if (prev) {
|
||||
const changes = new Set<string>();
|
||||
if (hasFieldChanged(id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
|
||||
if (hasFieldChanged(id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
|
||||
if (hasFieldChanged(id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
|
||||
if (hasFieldChanged(id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
|
||||
if (hasFieldChanged(id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
|
||||
if (hasFieldChanged(id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
|
||||
if (changes.size > 0) {
|
||||
newChangedFields.set(id, changes);
|
||||
}
|
||||
}
|
||||
|
||||
// Update changed fields and clear after animation duration
|
||||
changedFields = newChangedFields;
|
||||
if (newChangedFields.size > 0) {
|
||||
pendingTimeouts.push(setTimeout(() => {
|
||||
changedFields = new Map();
|
||||
}, 1500));
|
||||
}
|
||||
|
||||
// Store current stats as previous for next comparison
|
||||
previousStats = new Map(statsMap);
|
||||
containerStats = statsMap;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch container stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
changedFields = newChangedFields;
|
||||
if (newChangedFields.size > 0) {
|
||||
pendingTimeouts.push(setTimeout(() => {
|
||||
changedFields = new Map();
|
||||
}, 1500));
|
||||
}
|
||||
});
|
||||
|
||||
async function startContainer(id: string) {
|
||||
operationError = null;
|
||||
@@ -954,7 +855,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Started ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to start container:', error);
|
||||
operationError = { id, message: 'Failed to start container' };
|
||||
@@ -978,7 +879,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Stopped ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop container:', error);
|
||||
operationError = { id, message: 'Failed to stop container' };
|
||||
@@ -1003,7 +904,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Paused ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause container:', error);
|
||||
operationError = { id, message: 'Failed to pause container' };
|
||||
@@ -1026,7 +927,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Resumed ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to unpause container:', error);
|
||||
operationError = { id, message: 'Failed to unpause container' };
|
||||
@@ -1050,7 +951,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Restarted ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to restart container:', error);
|
||||
operationError = { id, message: 'Failed to restart container' };
|
||||
@@ -1075,7 +976,7 @@
|
||||
return;
|
||||
}
|
||||
toast.success(`Removed ${name}`);
|
||||
await fetchContainers();
|
||||
await containerStore.refreshContainers(envId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove container:', error);
|
||||
operationError = { id, message: 'Failed to remove container' };
|
||||
@@ -1379,8 +1280,8 @@
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && envId) {
|
||||
// Tab became visible - refresh data immediately
|
||||
fetchContainers();
|
||||
fetchStats();
|
||||
containerStore.refreshContainers(envId);
|
||||
containerStore.refreshStats(envId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,12 +1289,6 @@
|
||||
loadLayoutMode();
|
||||
loadStatusFilter();
|
||||
|
||||
// Check for image filter from URL params (from images page link)
|
||||
const imageParam = $page.url.searchParams.get('image');
|
||||
if (imageParam) {
|
||||
searchQuery = imageParam;
|
||||
}
|
||||
|
||||
// Load persisted pending updates from database
|
||||
loadPendingUpdates();
|
||||
|
||||
@@ -1405,14 +1300,14 @@
|
||||
|
||||
// Set up interval to refresh stats every 5 seconds (use module-scope var for cleanup)
|
||||
statsInterval = setInterval(() => {
|
||||
if (envId) fetchStats();
|
||||
if (envId) containerStore.refreshStats(envId);
|
||||
}, 5000);
|
||||
|
||||
// Subscribe to container events (SSE connection is global in layout)
|
||||
unsubscribeDockerEvent = onDockerEvent((event) => {
|
||||
if (envId && isContainerListChange(event)) {
|
||||
fetchContainers();
|
||||
fetchStats();
|
||||
containerStore.refreshContainers(envId);
|
||||
containerStore.refreshStats(envId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1634,12 +1529,12 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'restart')}
|
||||
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'restart')}
|
||||
<ConfirmPopover
|
||||
open={confirmBulkRestart}
|
||||
action="Restart"
|
||||
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
|
||||
title="Restart {selectedInFilter.length}"
|
||||
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
|
||||
title="Restart {selectedNonSystem.length}"
|
||||
variant="secondary"
|
||||
unstyled
|
||||
onConfirm={bulkRestart}
|
||||
@@ -1653,12 +1548,12 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'remove')}
|
||||
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmBulkRemove}
|
||||
action="Remove"
|
||||
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
|
||||
title="Remove {selectedInFilter.length}"
|
||||
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
|
||||
title="Remove {selectedNonSystem.length}"
|
||||
unstyled
|
||||
onConfirm={bulkRemove}
|
||||
onOpenChange={(open) => confirmBulkRemove = open}
|
||||
@@ -1897,7 +1792,7 @@
|
||||
{:else if column.id === 'ports'}
|
||||
{#if ports.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each ports.slice(0, 2) as port}
|
||||
{#each ports as port}
|
||||
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
||||
{#if url}
|
||||
<a
|
||||
@@ -1915,9 +1810,6 @@
|
||||
<code class="text-xs bg-muted px-1 py-0.5 rounded">{port.display}</code>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if ports.length > 2}
|
||||
<span class="text-xs text-muted-foreground">+{ports.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
||||
@@ -1943,13 +1835,20 @@
|
||||
{/if}
|
||||
{:else if column.id === 'stack'}
|
||||
{#if stack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
|
||||
</button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors truncate max-w-full">{stack}</Badge>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-sm whitespace-nowrap">{stack}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
||||
{/if}
|
||||
@@ -1965,6 +1864,7 @@
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !container.systemContainer}
|
||||
{#if container.state === 'running' || container.state === 'restarting'}
|
||||
{#if $canAccess('containers', 'stop')}
|
||||
<ConfirmPopover
|
||||
@@ -2030,6 +1930,7 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => inspectContainer(container)}
|
||||
@@ -2171,7 +2072,7 @@
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'remove')}
|
||||
{#if !container.systemContainer && $canAccess('containers', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === container.id}
|
||||
action="Delete"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
|
||||
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
|
||||
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -131,140 +132,123 @@
|
||||
throw new Error(data.error || 'Failed to start update');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const { jobId } = await response.json();
|
||||
const successIds: string[] = [];
|
||||
const failedIds: string[] = [];
|
||||
const blockedIds: string[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await watchJob(jobId, (line) => {
|
||||
try {
|
||||
const data = line.data as any;
|
||||
scrollTick++;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
if (data.type === 'start') {
|
||||
totalCount = data.total;
|
||||
} else if (data.type === 'progress') {
|
||||
currentIndex = data.current;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
// Update or add progress entry
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = data.step;
|
||||
progress[existingIndex].success = data.success;
|
||||
progress[existingIndex].error = data.error;
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
} else {
|
||||
progress = [...progress, {
|
||||
containerId: data.containerId,
|
||||
containerName: data.containerName,
|
||||
step: data.step,
|
||||
success: data.success,
|
||||
error: data.error,
|
||||
pullLogs: [],
|
||||
scanLogs: [],
|
||||
showLogs: true,
|
||||
}];
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
scrollTick++;
|
||||
|
||||
if (data.type === 'start') {
|
||||
totalCount = data.total;
|
||||
} else if (data.type === 'progress') {
|
||||
currentIndex = data.current;
|
||||
|
||||
// Update or add progress entry
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = data.step;
|
||||
progress[existingIndex].success = data.success;
|
||||
progress[existingIndex].error = data.error;
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
} else {
|
||||
progress = [...progress, {
|
||||
containerId: data.containerId,
|
||||
containerName: data.containerName,
|
||||
step: data.step,
|
||||
success: data.success,
|
||||
error: data.error,
|
||||
pullLogs: [],
|
||||
scanLogs: [],
|
||||
showLogs: true,
|
||||
}];
|
||||
}
|
||||
|
||||
// Track success/failed for onComplete callback
|
||||
if (data.success === true) {
|
||||
successIds.push(data.containerId);
|
||||
} else if (data.success === false && data.step === 'failed') {
|
||||
failedIds.push(data.containerId);
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
// Add pull log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
// For layer progress, update existing entry or add new
|
||||
if (data.pullId) {
|
||||
const existingLog = containerProgress.pullLogs.find(l => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
containerProgress.pullLogs.push({
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
// Track success/failed for onComplete callback
|
||||
if (data.success === true) {
|
||||
successIds.push(data.containerId);
|
||||
} else if (data.success === false && data.step === 'failed') {
|
||||
failedIds.push(data.containerId);
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
// Add pull log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
// For layer progress, update existing entry or add new
|
||||
if (data.pullId) {
|
||||
const existingLog = containerProgress.pullLogs.find((l: any) => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
// General status message (no layer ID)
|
||||
containerProgress.pullLogs.push({
|
||||
status: data.pullStatus
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
}
|
||||
} else if (data.type === 'scan_start') {
|
||||
// Update step to scanning
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.step = 'scanning';
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_log') {
|
||||
// Add scan log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanLogs.push({
|
||||
scanner: data.scanner,
|
||||
message: data.message
|
||||
} else {
|
||||
// General status message (no layer ID)
|
||||
containerProgress.pullLogs.push({
|
||||
status: data.pullStatus
|
||||
});
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_complete') {
|
||||
// Store scan result, individual scanner results, and vulnerabilities
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanResult = data.scanResult;
|
||||
containerProgress.scannerResults = data.scannerResults;
|
||||
containerProgress.vulnerabilities = data.vulnerabilities;
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'blocked') {
|
||||
// Mark container as blocked
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = 'blocked';
|
||||
progress[existingIndex].success = false;
|
||||
progress[existingIndex].scanResult = data.scanResult;
|
||||
progress[existingIndex].scannerResults = data.scannerResults;
|
||||
progress[existingIndex].blockReason = data.blockReason;
|
||||
progress = [...progress];
|
||||
}
|
||||
blockedIds.push(data.containerId);
|
||||
currentIndex = data.current;
|
||||
} else if (data.type === 'complete') {
|
||||
status = 'complete';
|
||||
summary = data.summary;
|
||||
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
|
||||
} else if (data.type === 'error') {
|
||||
status = 'error';
|
||||
errorMessage = data.error || 'Unknown error occurred';
|
||||
progress = [...progress]; // Trigger reactivity
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
} else if (data.type === 'scan_start') {
|
||||
// Update step to scanning
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.step = 'scanning';
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_log') {
|
||||
// Add scan log to the container's log list
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanLogs.push({
|
||||
scanner: data.scanner,
|
||||
message: data.message
|
||||
});
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'scan_complete') {
|
||||
// Store scan result, individual scanner results, and vulnerabilities
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
containerProgress.scanResult = data.scanResult;
|
||||
containerProgress.scannerResults = data.scannerResults;
|
||||
containerProgress.vulnerabilities = data.vulnerabilities;
|
||||
progress = [...progress];
|
||||
}
|
||||
} else if (data.type === 'blocked') {
|
||||
// Mark container as blocked
|
||||
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
|
||||
if (existingIndex >= 0) {
|
||||
progress[existingIndex].step = 'blocked';
|
||||
progress[existingIndex].success = false;
|
||||
progress[existingIndex].scanResult = data.scanResult;
|
||||
progress[existingIndex].scannerResults = data.scannerResults;
|
||||
progress[existingIndex].blockReason = data.blockReason;
|
||||
progress = [...progress];
|
||||
}
|
||||
blockedIds.push(data.containerId);
|
||||
currentIndex = data.current;
|
||||
} else if (data.type === 'complete') {
|
||||
status = 'complete';
|
||||
summary = data.summary;
|
||||
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
|
||||
} else if (data.type === 'error') {
|
||||
status = 'error';
|
||||
errorMessage = data.error || 'Unknown error occurred';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to process job line:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update containers:', error);
|
||||
status = 'error';
|
||||
@@ -343,63 +327,41 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
|
||||
throw new Error(data.error || 'Failed to start update');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
const { jobId } = await response.json();
|
||||
await watchJob(jobId, (line) => {
|
||||
const data = line.data as any;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
if (data.type === 'progress') {
|
||||
item.step = data.step;
|
||||
item.success = data.success;
|
||||
item.error = data.error;
|
||||
progress = [...progress];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'progress') {
|
||||
item.step = data.step;
|
||||
item.success = data.success;
|
||||
item.error = data.error;
|
||||
progress = [...progress];
|
||||
|
||||
// Update summary if container succeeded
|
||||
if (data.success === true && summary) {
|
||||
summary.blocked--;
|
||||
summary.success++;
|
||||
summary = { ...summary };
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
if (data.pullId) {
|
||||
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
item.pullLogs.push({
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
} else {
|
||||
item.pullLogs.push({ status: data.pullStatus });
|
||||
}
|
||||
progress = [...progress];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
// Update summary if container succeeded
|
||||
if (data.success === true && summary) {
|
||||
summary.blocked--;
|
||||
summary.success++;
|
||||
summary = { ...summary };
|
||||
}
|
||||
} else if (data.type === 'pull_log') {
|
||||
if (data.pullId) {
|
||||
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
|
||||
if (existingLog) {
|
||||
existingLog.status = data.pullStatus;
|
||||
existingLog.progress = data.pullProgress;
|
||||
} else {
|
||||
item.pullLogs.push({
|
||||
status: data.pullStatus,
|
||||
id: data.pullId,
|
||||
progress: data.pullProgress
|
||||
});
|
||||
}
|
||||
} else {
|
||||
item.pullLogs.push({ status: data.pullStatus });
|
||||
}
|
||||
progress = [...progress];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to force update container:', error);
|
||||
item.step = 'failed';
|
||||
|
||||
@@ -452,9 +452,22 @@
|
||||
healthcheckTimeout = Math.floor((healthcheck.Timeout || 30e9) / 1e9);
|
||||
healthcheckRetries = healthcheck.Retries || 3;
|
||||
healthcheckStartPeriod = Math.floor((healthcheck.StartPeriod || 0) / 1e9);
|
||||
} else {
|
||||
healthcheckEnabled = false;
|
||||
healthcheckCommand = '';
|
||||
healthcheckInterval = 30;
|
||||
healthcheckTimeout = 30;
|
||||
healthcheckRetries = 3;
|
||||
healthcheckStartPeriod = 0;
|
||||
}
|
||||
|
||||
// Parse advanced options - Resources
|
||||
// Parse advanced options - Resources (reset first to avoid stale values)
|
||||
memoryLimit = '';
|
||||
memoryReservation = '';
|
||||
cpuShares = '';
|
||||
nanoCpus = '';
|
||||
cpuQuota = '';
|
||||
cpuPeriod = '';
|
||||
if (data.HostConfig.Memory) {
|
||||
memoryLimit = formatBytes(data.HostConfig.Memory);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user