Compare commits

..

25 Commits

Author SHA1 Message Date
jarek 107e9c3758 1.0.8 2026-01-14 08:18:20 +01:00
sieren f972378117 Mobile: Only show total of stacks
The detailed display of stacks (following x/x/x/x) is too wide
for mobile display.
So for mobile display only, we limit this information to the total number
of stacks.
2026-01-12 14:25:53 +01:00
sieren f588ed787b Improve Environment Layout on Mobile
Do not use the grid layout on mobile but show each tile
in a scrollable list instead.
2026-01-12 14:25:53 +01:00
Jarek Krochmalski 6baf6c23e8 1.0.7 2026-01-11 09:01:42 +01:00
Jarek Krochmalski 6382b4083e 1.0.7 2026-01-11 07:17:25 +01:00
Jarek Krochmalski b269b8d50d 1.0.7 2026-01-11 07:16:18 +01:00
jarek 410d542c58 1.0.6 2026-01-03 14:56:20 +01:00
jarek a02115e6bc missing scripts 2026-01-03 13:21:38 +01:00
jarek 86e4c9eb56 1.0.5 2026-01-03 09:10:38 +01:00
jarek c46870afd1 1.0.5 2026-01-02 15:39:51 +01:00
jarek a8a5623c10 1.0.5 2026-01-02 15:29:56 +01:00
Jarek Krochmalski 059ecbb1dc Update README.md 2026-01-02 13:38:16 +01:00
Jarek Krochmalski 3eab42169c Update README.md 2026-01-02 13:36:32 +01:00
jarek 6a7116a5b7 1.0.5 2026-01-02 12:24:43 +01:00
jarek 215f52b1f0 1.0.5 2026-01-01 16:32:08 +01:00
jarek de62327a07 1.0.5 2026-01-01 16:05:10 +01:00
jarek cd6544aedb 1.0.5 2026-01-01 16:00:34 +01:00
jarek c60db2930c compose example 2025-12-29 15:29:33 +01:00
Jarek Krochmalski 695acd922e bmac 2025-12-29 13:46:13 +01:00
Jarek Krochmalski fcb36c4646 bmac 2025-12-29 13:39:36 +01:00
Jarek Krochmalski 53ca99ac77 Update README.md 2025-12-29 13:37:26 +01:00
Jarek Krochmalski 81fcc28d0b Update LICENSE.txt 2025-12-29 09:27:27 +01:00
jarek 522154cd68 ignore 2025-12-29 09:08:29 +01:00
jarek 9db6e67a61 drizzle config 2025-12-29 09:08:06 +01:00
Jarek Krochmalski ba05d16d79 cleanup .DS_Store 2025-12-29 08:55:55 +01:00
182 changed files with 38307 additions and 3488 deletions
+3
View File
@@ -0,0 +1,3 @@
buy_me_a_coffee:
displayName: "Buy Me a Coffee"
account: dockhand
+2
View File
@@ -0,0 +1,2 @@
.idea/
.DS_Store
+145 -48
View File
@@ -1,11 +1,90 @@
# Build stage - using Debian to avoid Alpine musl thread creation issues
# syntax=docker/dockerfile:1.4
# =============================================================================
# Dockhand Docker Image - 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 .
# =============================================================================
# -----------------------------------------------------------------------------
# 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
ARG APKO_VERSION=0.30.34
RUN apk add --no-cache curl unzip \
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
&& curl -sL "https://github.com/chainguard-dev/apko/releases/download/v${APKO_VERSION}/apko_${APKO_VERSION}_linux_${ARCH}.tar.gz" \
| 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
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
&& printf '%s\n' \
"contents:" \
" repositories:" \
" - https://packages.wolfi.dev/os" \
" keyring:" \
" - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub" \
" packages:" \
" - wolfi-base" \
" - ca-certificates" \
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose" \
" - sqlite" \
" - git" \
" - openssh-client" \
" - curl" \
" - tini" \
" - su-exec" \
"entrypoint:" \
" command: /bin/sh -l" \
"archs:" \
" - ${APKO_ARCH}" \
> 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 \
&& LAYER=$(tar -tf output.tar | grep '.tar.gz$' | head -1) \
&& tar -xzf "$LAYER" -C rootfs
# -----------------------------------------------------------------------------
# Stage 2: Application Builder
# -----------------------------------------------------------------------------
# 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 builder
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
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends jq git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy package files and install ALL dependencies (needed for build)
COPY package.json bun.lock* bunfig.toml ./
@@ -15,72 +94,90 @@ RUN bun install --frozen-lockfile
COPY . .
# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM
# Increased memory limits for parallel compilation with larger semi-space for GC
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build
# Production stage - minimal Alpine with Bun runtime
FROM oven/bun:1.3.5-alpine
# 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
# -----------------------------------------------------------------------------
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
# -----------------------------------------------------------------------------
FROM scratch
# Install our custom-built Wolfi OS (now we have /bin/sh!)
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
WORKDIR /app
# Install runtime dependencies, create user
# Add sqlite for emergency scripts, git for stack git operations, curl for healthchecks
# Add docker-cli and docker-cli-compose for stack management (uses host's docker socket)
# Add openssh-client for SSH key authentication with git repositories
# Upgrade all packages to latest versions for security patches
RUN apk upgrade --no-cache \
&& apk add --no-cache curl git tini su-exec sqlite docker-cli docker-cli-compose openssh-client iproute2 \
&& addgroup -g 1001 dockhand \
# Set up environment variables
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
DATA_DIR=/app/data \
HOME=/home/dockhand \
PUID=1001 \
PGID=1001
# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary)
RUN mkdir -p /usr/libexec/docker/cli-plugins \
&& ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
# Create dockhand user and group (using busybox commands)
RUN addgroup -g 1001 dockhand \
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
# Copy package files and install production dependencies
# This is needed because svelte-adapter-bun externalizes some packages (croner, etc.)
# that need to be available at runtime. Installing at build time is more reliable
# than Bun's auto-install which requires network access and writable cache.
COPY package.json bun.lock* ./
RUN bun install --production --frozen-lockfile
# Copy built application (Bun adapter output)
COPY --from=builder /app/build ./build
# Copy bundled subprocess scripts (built by scripts/build-subprocesses.ts)
COPY --from=builder /app/build/subprocesses/ ./subprocesses/
# Copy application files with correct ownership (avoids layer duplication from chown -R)
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 database migrations
COPY drizzle/ ./drizzle/
COPY drizzle-pg/ ./drizzle-pg/
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
# Copy legal documents
COPY LICENSE.txt PRIVACY.txt ./
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
# Copy entrypoint script
# Copy entrypoint script (root-owned, executable)
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy emergency scripts (only the emergency subfolder, not license generation scripts)
COPY scripts/emergency/ ./scripts/
RUN chmod +x ./scripts/*.sh 2>/dev/null || true
# Copy emergency scripts
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
# Create directories with proper ownership
# Create data directories with correct ownership
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
&& chown -R dockhand:dockhand /app /home/dockhand
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
EXPOSE 3000
# Runtime configuration
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
ENV DATA_DIR=/app/data
ENV HOME=/home/dockhand
# User/group IDs - customize with -e PUID=1000 -e PGID=1000
# The entrypoint will recreate the dockhand user with these IDs
ENV PUID=1001
ENV PGID=1001
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
CMD curl -f http://localhost:3000/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["bun", "run", "./build/index.js"]
+1 -1
View File
@@ -123,6 +123,6 @@ under an Open Source License, as stated in this License.
For licensing inquiries, commercial licensing, or enterprise features:
Website: https://dockhand.io
Website: https://dockhand.pro
-----------------------------------------------------------------------------
+425
View File
@@ -0,0 +1,425 @@
DOCKHAND PRIVACY POLICY
Last Updated: December 14, 2025
Effective Date: December 14, 2025
================================================================================
1. INTRODUCTION
This Privacy Policy describes how Finsys Jaroslaw Krochmalski ("Finsys," "we,"
"us," or "our") handles data in connection with the Dockhand software
application ("Software"). This Policy applies to all users of the Software.
Finsys is committed to protecting your privacy and ensuring transparency
about our data practices. This Policy explains that the Software operates
entirely locally on your infrastructure with no data transmitted to Finsys.
2. DATA CONTROLLER INFORMATION
Finsys Jaroslaw Krochmalski
ul. Borki 6
05-119 Jozefow
Poland
VAT ID: PL7121835977
REGON: 061576391
Email: enterprise@dockhand.pro
Website: https://dockhand.pro
For the purpose of the General Data Protection Regulation (GDPR) and other
applicable data protection laws, Finsys is NOT the data controller for any
personal data processed through your installation of the Software. You (the
user or your organization) are the data controller for all data stored in
your Software installation.
3. OUR FUNDAMENTAL PRINCIPLE: LOCAL-ONLY DATA
The Software is designed with privacy as a core principle:
- ALL DATA STAYS LOCAL: The Software stores all data exclusively on your
infrastructure (your servers, your databases, your storage).
- NO DATA TRANSMISSION: The Software does not transmit any data to Finsys
servers, third-party servers, or any external services.
- NO TELEMETRY: The Software contains no telemetry, analytics, usage
tracking, crash reporting, or any other data collection mechanisms.
- FULLY SELF-CONTAINED: The Software operates entirely within your
infrastructure without requiring any connection to Finsys systems.
- FINSYS HAS NO ACCESS: Finsys cannot access, view, retrieve, or process
any data stored in your Software installation.
4. DATA PROCESSED BY THE SOFTWARE
When you use the Software, the following types of data may be stored
LOCALLY on your infrastructure:
4.1 User Account Data
- Usernames and email addresses
- Password hashes (never stored in plain text)
- Multi-factor authentication (MFA) secrets (Enterprise Edition)
- User profile information and avatars
- Role assignments and permissions (Enterprise Edition)
4.2 Authentication Data
- Session tokens and cookies
- OIDC/SSO tokens and provider configurations
- LDAP/Active Directory connection settings (Enterprise Edition)
- API tokens for remote access
4.3 Docker Environment Data
- Docker host connection details (URLs, ports, socket paths)
- Docker container information (names, IDs, configurations)
- Container logs and metrics
- Image and volume data
- Network configurations
- Compose stack definitions
4.4 Git Integration Data
- Git repository URLs and credentials
- SSH keys and access tokens
- Deployment webhooks
4.5 Registry Data
- Docker registry URLs and credentials
- Image pull/push history
4.6 Activity and Audit Data
- User activity logs
- Container events and operations
- Audit trails (Enterprise Edition)
4.7 Application Settings
- General configuration preferences
- Notification channel settings (SMTP, webhooks)
- Scheduled task configurations
All of the above data is stored exclusively in your local database
(SQLite or PostgreSQL) and on your local filesystem. None of this data
is transmitted to or accessible by Finsys.
5. HOW DATA IS STORED
5.1 Database Storage
The Software uses either SQLite or PostgreSQL as configured by you:
- SQLite: Data stored in a local file on your server
- PostgreSQL: Data stored in your PostgreSQL database instance
5.2 File Storage
Certain data is stored in the local filesystem:
- Compose stack files
- Uploaded files (e.g., user avatars)
- Temporary files during operations
5.3 Encryption
- Passwords are hashed using secure algorithms (Argon2id)
- Sensitive credentials may be encrypted at rest depending on your
database configuration
- You are responsible for implementing disk encryption, database
encryption, and network security for your infrastructure
6. YOUR RESPONSIBILITIES AS DATA CONTROLLER
Since all data is stored locally on your infrastructure, YOU are the
data controller for purposes of GDPR and other data protection laws.
As data controller, you are responsible for:
6.1 Legal Basis for Processing
Ensuring you have a valid legal basis for processing personal data of
your users (e.g., consent, legitimate interest, contractual necessity).
6.2 Data Subject Rights
Responding to data subject requests including:
- Right of access (Article 15 GDPR)
- Right to rectification (Article 16 GDPR)
- Right to erasure (Article 17 GDPR)
- Right to restriction of processing (Article 18 GDPR)
- Right to data portability (Article 20 GDPR)
- Right to object (Article 21 GDPR)
6.3 Security Measures
Implementing appropriate technical and organizational measures to
protect personal data, including:
- Access controls and authentication
- Encryption of data at rest and in transit
- Regular security updates and patches
- Backup and disaster recovery procedures
- Network security (firewalls, VPNs, etc.)
6.4 Data Retention
Establishing and implementing appropriate data retention policies.
6.5 Breach Notification
Notifying supervisory authorities and affected individuals in case
of a personal data breach, as required by applicable law.
6.6 Privacy Notices
Providing appropriate privacy notices to your users regarding how
their data is processed within the Software.
7. DATA WE DO NOT COLLECT
To be absolutely clear, Finsys does NOT collect, receive, access, or
process ANY of the following:
- Your identity or contact information (unless you contact us directly)
- Your Docker infrastructure information
- Your container configurations or data
- Your user accounts or credentials
- Your activity logs or audit trails
- Your git repositories or deployment data
- Usage statistics or analytics
- Error reports or crash data
- Any telemetry or diagnostic data
- Any data whatsoever from your Software installation
8. WHEN FINSYS MAY RECEIVE DATA
The only circumstances in which Finsys may receive data from you are:
8.1 Direct Communication
When you voluntarily contact us via email (enterprise@dockhand.pro),
we receive and process the information you provide (name, email address,
message content). This data is processed for the purpose of responding
to your inquiry based on our legitimate interest in providing customer
support.
8.2 License Purchase
When you purchase an Enterprise Edition license, we collect and process:
Data Collected:
- Name and/or company name
- Email address
- Billing address
- Payment information (processed by payment provider)
- Licensed hostname/identifier
Legal Basis (GDPR Article 6):
- Contract performance (Art. 6(1)(b)) - to fulfill the license agreement
- Legal obligation (Art. 6(1)(c)) - for invoicing and tax records
How We Use This Data:
- To issue and deliver your License Key
- To send license renewal reminders
- To provide support related to your license
- To comply with tax and accounting obligations
Data Retention:
- License and invoice records: 7 years (Polish tax law requirement)
- Email correspondence: 3 years after last contact
Data Sharing:
- Payment processor (for payment transactions only)
- No other third parties
- No marketing or advertising use
8.3 Website Visits
If you visit our website (https://dockhand.pro), standard web server
logs may be collected. See our website privacy policy for details.
9. LICENSE KEY DATA
Enterprise Edition License Keys contain:
- Customer name (as registered)
- Licensed hostname or identifier
- Expiration date
- Cryptographic signature
This information is embedded in the License Key itself and stored
locally in your Software installation. Finsys retains a record of
issued licenses for license management purposes.
10. INTERNATIONAL DATA TRANSFERS
Since all Software data is stored locally on your infrastructure, no
international data transfers occur through the Software itself.
If your infrastructure is located outside the European Economic Area
(EEA), you are responsible for ensuring appropriate safeguards for
any personal data stored therein.
11. DATA RETENTION
11.1 Software Data
You control the retention of all data in your Software installation.
The Software does not automatically delete data unless you configure
retention policies or manually delete data.
11.2 Communication Data
If you contact us directly, we retain correspondence for as long as
necessary to respond to your inquiry and for our records, typically
not exceeding 3 years unless required for legal purposes.
11.3 License Records
We retain license purchase and activation records for the duration
required by tax and accounting regulations (typically 5-7 years).
12. CHILDREN'S PRIVACY
The Software is not intended for use by children under 16 years of age.
We do not knowingly collect personal data from children. If you are a
parent or guardian and believe your child has provided personal data
to us through direct communication, please contact us.
13. THIRD-PARTY SERVICES
13.1 Software Integrations
The Software may connect to third-party services as configured by you:
- Docker registries
- Git repositories (GitHub, GitLab, etc.)
- OIDC/SSO providers
- LDAP/Active Directory servers
- Notification services (SMTP, Discord, Slack, etc.)
These connections are initiated by you, configured by you, and occur
between your infrastructure and these third-party services. Finsys is
not involved in these connections and has no access to the data
exchanged. The privacy policies of these third-party services apply
to your use of them.
13.2 No Hidden Third-Party Data Sharing
The Software does not share any data with third parties on our behalf.
There are no embedded analytics services, advertising networks, or
data brokers within the Software.
14. SECURITY
14.1 Software Security
We implement security measures in the Software design:
- Secure password hashing (Argon2id)
- Session management with secure tokens
- Input validation and sanitization
- Protection against common web vulnerabilities
14.2 Your Security Responsibilities
Since all data is stored on your infrastructure, you are responsible
for:
- Keeping the Software updated
- Securing your server and database
- Implementing network security measures
- Managing user access and authentication
- Creating and securing backups
15. CHANGES TO THIS PRIVACY POLICY
We may update this Privacy Policy from time to time. Material changes
will be communicated through:
- Updated "Last Updated" date at the top of this Policy
- Notice on our website
- Notice within the Software (for significant changes)
We encourage you to review this Privacy Policy periodically.
16. GDPR COMPLIANCE
Finsys complies with the General Data Protection Regulation (EU) 2016/679.
Summary of Our Data Processing:
- We only collect personal data (email, name) when you purchase a license
- Legal basis: Contract performance and legal obligation
- Data is stored securely in the EU (Poland)
- Retention: 7 years for tax records, 3 years for correspondence
- No automated decision-making or profiling
- No data sold or shared for marketing purposes
Your GDPR Rights (Articles 15-22):
You have the right to access, rectify, erase, restrict processing,
data portability, and object to processing of your personal data.
To exercise any of these rights, contact: enterprise@dockhand.pro
We will respond within 30 days as required by GDPR.
17. YOUR RIGHTS
If you are located in the European Economic Area (EEA), United Kingdom,
or other jurisdiction with data protection laws, you have rights
regarding personal data we hold about you (from direct communications
or license purchases):
- Access: Request access to personal data we hold about you
- Rectification: Request correction of inaccurate data
- Erasure: Request deletion of your data
- Restriction: Request restriction of processing
- Portability: Request a copy of your data in portable format
- Objection: Object to processing based on legitimate interests
- Complaint: Lodge a complaint with a supervisory authority
To exercise these rights, contact us at enterprise@dockhand.pro.
Note: These rights apply to data WE hold (from direct communication or
license purchases), not to data in YOUR Software installation. For data
in your installation, YOU are the data controller and responsible for
handling such requests from your users.
18. SUPERVISORY AUTHORITY
If you are located in Poland, the relevant supervisory authority is:
Urzad Ochrony Danych Osobowych (UODO)
ul. Stawki 2
00-193 Warszawa
Poland
https://uodo.gov.pl
If you are located in another EEA country, you may contact your local
data protection authority.
19. CONTACT US
For any privacy-related questions, concerns, or requests:
Finsys Jaroslaw Krochmalski
ul. Borki 6
05-119 Jozefow
Poland
Email: enterprise@dockhand.pro
Website: https://dockhand.pro
================================================================================
SUMMARY
Dockhand is a privacy-respecting application:
- All data stays on YOUR infrastructure
- NO data is sent to Finsys servers
- NO telemetry or analytics
- YOU are the data controller for your installation
- Finsys has NO access to your data
We believe privacy is a fundamental right, and we have designed Dockhand
to respect that right by ensuring you maintain complete control over your
data at all times.
================================================================================
Copyright (c) 2025-2026 Finsys Jaroslaw Krochmalski. All rights reserved.
+10 -1
View File
@@ -16,7 +16,7 @@
## About
Dockhand is a modern, efficient Docker management application providing real-time container management, Compose stack orchestration, and multi-environment support.
Dockhand is a modern, efficient Docker management application providing real-time container management, Compose stack orchestration, and multi-environment support. All in a lightweight, secure and privacy-focused package.
### Features
@@ -30,6 +30,7 @@ Dockhand is a modern, efficient Docker management application providing real-tim
## Tech Stack
- **Base**: own OS layer built from scratch using <a href="https://github.com/wolfi-dev/os">Wolfi packages</a> via apko. Every package is explicitly declared in the Dockerfile.
- **Frontend**: SvelteKit 2, Svelte 5, shadcn-svelte, TailwindCSS
- **Backend**: Bun runtime with SvelteKit API routes
- **Database**: SQLite or PostgreSQL via Drizzle ORM
@@ -47,6 +48,14 @@ Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1
See [LICENSE.txt](LICENSE.txt) for full terms.
<a href="https://buymeacoffee.com/dockhand" target="_blank">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png"
alt="Buy Me A Coffee"
height="40">
</a>
## Links
- **Website**: [https://dockhand.pro](https://dockhand.pro)
+25
View File
@@ -0,0 +1,25 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: dockhand
POSTGRES_PASSWORD: changeme
POSTGRES_DB: dockhand
volumes:
- postgres_data:/var/lib/postgresql/data
dockhand:
image: fnsys/dockhand:latest
ports:
- 3000:3000
environment:
DATABASE_URL: postgres://dockhand:changeme@postgres:5432/dockhand
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dockhand_data:/app/data
depends_on:
- postgres
volumes:
postgres_data:
dockhand_data:
+13
View File
@@ -0,0 +1,13 @@
services:
dockhand:
image: fnsys/dockhand:latest
container_name: dockhand
restart: unless-stopped
ports:
- 3000:3000
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dockhand_data:/app/data
volumes:
dockhand_data:
+82 -13
View File
@@ -12,6 +12,60 @@ if [ "$(id -u)" = "0" ]; then
RUNNING_AS_ROOT=true
fi
# === Non-root mode (user: directive in compose) ===
# If container started as non-root, skip all user management and run directly
if [ "$RUNNING_AS_ROOT" = "false" ]; then
echo "Running as user $(id -u):$(id -g) (set via container user directive)"
# Ensure data directories exist (user must have write access to DATA_DIR via volume mount)
DATA_DIR="${DATA_DIR:-/app/data}"
if [ ! -d "$DATA_DIR/db" ]; then
echo "Creating database directory at $DATA_DIR/db"
mkdir -p "$DATA_DIR/db" 2>/dev/null || {
echo "ERROR: Cannot create $DATA_DIR/db directory"
echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)"
echo ""
echo "Example docker-compose.yml:"
echo " volumes:"
echo " - ./data:/app/data # This directory must be writable by user $(id -u)"
exit 1
}
fi
if [ ! -d "$DATA_DIR/stacks" ]; then
mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true
fi
# Check Docker socket access if mounted
SOCKET_PATH="/var/run/docker.sock"
if [ -S "$SOCKET_PATH" ]; then
if test -r "$SOCKET_PATH" 2>/dev/null; then
echo "Docker socket accessible at $SOCKET_PATH"
# Detect hostname from Docker if not set
if [ -z "$DOCKHAND_HOSTNAME" ]; then
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
if [ -n "$DETECTED_HOSTNAME" ]; then
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
fi
fi
else
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
echo "WARNING: Docker socket not readable by user $(id -u)"
echo "Add --group-add $SOCKET_GID to your docker run command"
fi
else
echo "No Docker socket found at $SOCKET_PATH"
echo "Configure Docker environments via the web UI (Settings > Environments)"
fi
# Run directly as current user (no su-exec needed)
if [ "$1" = "" ]; then
exec bun run ./build/index.js
else
exec "$@"
fi
fi
# === User Setup ===
# Root mode: PUID=0 requested OR already running as root with default PUID/PGID
if [ "$PUID" = "0" ]; then
@@ -26,15 +80,24 @@ else
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
echo "Configuring user with PUID=$PUID PGID=$PGID"
# Remove existing dockhand user/group (only dockhand, not others)
# Remove existing dockhand user/group (using busybox commands)
deluser dockhand 2>/dev/null || true
delgroup dockhand 2>/dev/null || true
# Check for UID conflicts - warn but don't delete other users
SKIP_USER_CREATE=false
if getent passwd "$PUID" >/dev/null 2>&1; then
EXISTING=$(getent passwd "$PUID" | cut -d: -f1)
echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001."
PUID=1001
if [ "$EXISTING" = "bun" ]; then
echo "Note: UID $PUID is used by the 'bun' runtime user - reusing it for dockhand"
echo "If upgrading from a previous version, you may need to fix data permissions:"
echo " chown -R $PUID:$PGID /path/to/your/data"
RUN_USER="bun"
SKIP_USER_CREATE=true
else
echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001."
PUID=1001
fi
fi
# Handle GID - reuse existing group or create new
@@ -45,21 +108,26 @@ else
TARGET_GROUP="dockhand"
fi
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
if [ "$SKIP_USER_CREATE" = "false" ]; then
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
fi
fi
# === Directory Ownership ===
chown -R dockhand:dockhand /app/data /home/dockhand 2>/dev/null || true
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
if [ "$RUN_USER" = "dockhand" ]; then
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
mkdir -p "$DATA_DIR"
chown -R dockhand:dockhand "$DATA_DIR" 2>/dev/null || true
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
fi
fi
# === Docker Socket Access (Optional) ===
# Check if Docker socket is mounted and accessible
# Socket path can be configured via environment-specific settings in the app
# Note: DOCKER_HOST with tcp:// requires configuring an environment via the web UI
SOCKET_PATH="/var/run/docker.sock"
if [ -S "$SOCKET_PATH" ]; then
@@ -67,7 +135,7 @@ if [ -S "$SOCKET_PATH" ]; then
if [ "$RUN_USER" != "root" ]; then
if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
echo "WARNING: Docker socket at $SOCKET_PATH is not readable by dockhand user"
echo "WARNING: Docker socket at $SOCKET_PATH is not readable by $RUN_USER user"
echo ""
echo "To use local Docker, fix with one of these options:"
echo ""
@@ -100,8 +168,8 @@ if [ -S "$SOCKET_PATH" ]; then
echo "Using configured hostname: $DOCKHAND_HOSTNAME"
fi
else
echo "No Docker socket found at $SOCKET_PATH"
echo "Configure Docker environments via the web UI (Settings > Environments)"
echo "No local Docker socket mounted (this is normal when using socket-proxy or remote Docker)"
echo "Configure your Docker environment via the web UI: Settings > Environments"
fi
# === Run Application ===
@@ -113,10 +181,11 @@ if [ "$RUN_USER" = "root" ]; then
exec "$@"
fi
else
# Running as dockhand user
# Running as non-root user
echo "Running as user: $RUN_USER"
if [ "$1" = "" ]; then
exec su-exec dockhand bun run ./build/index.js
exec su-exec "$RUN_USER" bun run ./build/index.js
else
exec su-exec dockhand "$@"
exec su-exec "$RUN_USER" "$@"
fi
fi
+401
View File
@@ -0,0 +1,401 @@
CREATE TABLE "audit_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"username" text NOT NULL,
"action" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text,
"entity_name" text,
"environment_id" integer,
"description" text,
"details" text,
"ip_address" text,
"user_agent" text,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "auth_settings" (
"id" serial PRIMARY KEY NOT NULL,
"auth_enabled" boolean DEFAULT false,
"default_provider" text DEFAULT 'local',
"session_timeout" integer DEFAULT 86400,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "auto_update_settings" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer,
"container_name" text NOT NULL,
"enabled" boolean DEFAULT false,
"schedule_type" text DEFAULT 'daily',
"cron_expression" text,
"vulnerability_criteria" text DEFAULT 'never',
"last_checked" timestamp,
"last_updated" timestamp,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "auto_update_settings_environment_id_container_name_unique" UNIQUE("environment_id","container_name")
);
--> statement-breakpoint
CREATE TABLE "config_sets" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"env_vars" text,
"labels" text,
"ports" text,
"volumes" text,
"network_mode" text DEFAULT 'bridge',
"restart_policy" text DEFAULT 'no',
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "config_sets_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "container_events" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer,
"container_id" text NOT NULL,
"container_name" text,
"image" text,
"action" text NOT NULL,
"actor_attributes" text,
"timestamp" timestamp NOT NULL,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "environment_notifications" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer NOT NULL,
"notification_id" integer NOT NULL,
"enabled" boolean DEFAULT true,
"event_types" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "environment_notifications_environment_id_notification_id_unique" UNIQUE("environment_id","notification_id")
);
--> statement-breakpoint
CREATE TABLE "environments" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"host" text,
"port" integer DEFAULT 2375,
"protocol" text DEFAULT 'http',
"tls_ca" text,
"tls_cert" text,
"tls_key" text,
"tls_skip_verify" boolean DEFAULT false,
"icon" text DEFAULT 'globe',
"collect_activity" boolean DEFAULT true,
"collect_metrics" boolean DEFAULT true,
"highlight_changes" boolean DEFAULT true,
"labels" text,
"connection_type" text DEFAULT 'socket',
"socket_path" text DEFAULT '/var/run/docker.sock',
"hawser_token" text,
"hawser_last_seen" timestamp,
"hawser_agent_id" text,
"hawser_agent_name" text,
"hawser_version" text,
"hawser_capabilities" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "environments_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "git_credentials" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"auth_type" text DEFAULT 'none' NOT NULL,
"username" text,
"password" text,
"ssh_private_key" text,
"ssh_passphrase" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "git_credentials_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "git_repositories" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"branch" text DEFAULT 'main',
"credential_id" integer,
"compose_path" text DEFAULT 'docker-compose.yml',
"environment_id" integer,
"auto_update" boolean DEFAULT false,
"auto_update_schedule" text DEFAULT 'daily',
"auto_update_cron" text DEFAULT '0 3 * * *',
"webhook_enabled" boolean DEFAULT false,
"webhook_secret" text,
"last_sync" timestamp,
"last_commit" text,
"sync_status" text DEFAULT 'pending',
"sync_error" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "git_repositories_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "git_stacks" (
"id" serial PRIMARY KEY NOT NULL,
"stack_name" text NOT NULL,
"environment_id" integer,
"repository_id" integer NOT NULL,
"compose_path" text DEFAULT 'docker-compose.yml',
"auto_update" boolean DEFAULT false,
"auto_update_schedule" text DEFAULT 'daily',
"auto_update_cron" text DEFAULT '0 3 * * *',
"webhook_enabled" boolean DEFAULT false,
"webhook_secret" text,
"last_sync" timestamp,
"last_commit" text,
"sync_status" text DEFAULT 'pending',
"sync_error" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "git_stacks_stack_name_environment_id_unique" UNIQUE("stack_name","environment_id")
);
--> statement-breakpoint
CREATE TABLE "hawser_tokens" (
"id" serial PRIMARY KEY NOT NULL,
"token" text NOT NULL,
"token_prefix" text NOT NULL,
"name" text NOT NULL,
"environment_id" integer,
"is_active" boolean DEFAULT true,
"last_used" timestamp,
"created_at" timestamp DEFAULT now(),
"expires_at" timestamp,
CONSTRAINT "hawser_tokens_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "host_metrics" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer,
"cpu_percent" double precision NOT NULL,
"memory_percent" double precision NOT NULL,
"memory_used" bigint,
"memory_total" bigint,
"timestamp" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "ldap_config" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"enabled" boolean DEFAULT false,
"server_url" text NOT NULL,
"bind_dn" text,
"bind_password" text,
"base_dn" text NOT NULL,
"user_filter" text DEFAULT '(uid={{username}})',
"username_attribute" text DEFAULT 'uid',
"email_attribute" text DEFAULT 'mail',
"display_name_attribute" text DEFAULT 'cn',
"group_base_dn" text,
"group_filter" text,
"admin_group" text,
"role_mappings" text,
"tls_enabled" boolean DEFAULT false,
"tls_ca" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "notification_settings" (
"id" serial PRIMARY KEY NOT NULL,
"type" text NOT NULL,
"name" text NOT NULL,
"enabled" boolean DEFAULT true,
"config" text NOT NULL,
"event_types" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "oidc_config" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"enabled" boolean DEFAULT false,
"issuer_url" text NOT NULL,
"client_id" text NOT NULL,
"client_secret" text NOT NULL,
"redirect_uri" text NOT NULL,
"scopes" text DEFAULT 'openid profile email',
"username_claim" text DEFAULT 'preferred_username',
"email_claim" text DEFAULT 'email',
"display_name_claim" text DEFAULT 'name',
"admin_claim" text,
"admin_value" text,
"role_mappings_claim" text DEFAULT 'groups',
"role_mappings" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "registries" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"username" text,
"password" text,
"is_default" boolean DEFAULT false,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "registries_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "roles" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"is_system" boolean DEFAULT false,
"permissions" text NOT NULL,
"environment_ids" text,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "roles_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "schedule_executions" (
"id" serial PRIMARY KEY NOT NULL,
"schedule_type" text NOT NULL,
"schedule_id" integer NOT NULL,
"environment_id" integer,
"entity_name" text NOT NULL,
"triggered_by" text NOT NULL,
"triggered_at" timestamp NOT NULL,
"started_at" timestamp,
"completed_at" timestamp,
"duration" integer,
"status" text NOT NULL,
"error_message" text,
"details" text,
"logs" text,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "settings" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "stack_events" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer,
"stack_name" text NOT NULL,
"event_type" text NOT NULL,
"timestamp" timestamp DEFAULT now(),
"metadata" text
);
--> statement-breakpoint
CREATE TABLE "stack_sources" (
"id" serial PRIMARY KEY NOT NULL,
"stack_name" text NOT NULL,
"environment_id" integer,
"source_type" text DEFAULT 'internal' NOT NULL,
"git_repository_id" integer,
"git_stack_id" integer,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "stack_sources_stack_name_environment_id_unique" UNIQUE("stack_name","environment_id")
);
--> statement-breakpoint
CREATE TABLE "user_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"environment_id" integer,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "user_preferences_user_id_environment_id_key_unique" UNIQUE("user_id","environment_id","key")
);
--> statement-breakpoint
CREATE TABLE "user_roles" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"role_id" integer NOT NULL,
"environment_id" integer,
"created_at" timestamp DEFAULT now(),
CONSTRAINT "user_roles_user_id_role_id_environment_id_unique" UNIQUE("user_id","role_id","environment_id")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" text NOT NULL,
"email" text,
"password_hash" text NOT NULL,
"display_name" text,
"avatar" text,
"auth_provider" text DEFAULT 'local',
"mfa_enabled" boolean DEFAULT false,
"mfa_secret" text,
"is_active" boolean DEFAULT true,
"last_login" timestamp,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "vulnerability_scans" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer,
"image_id" text NOT NULL,
"image_name" text NOT NULL,
"scanner" text NOT NULL,
"scanned_at" timestamp NOT NULL,
"scan_duration" integer,
"critical_count" integer DEFAULT 0,
"high_count" integer DEFAULT 0,
"medium_count" integer DEFAULT 0,
"low_count" integer DEFAULT 0,
"negligible_count" integer DEFAULT 0,
"unknown_count" integer DEFAULT 0,
"vulnerabilities" text,
"error" text,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auto_update_settings" ADD CONSTRAINT "auto_update_settings_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "container_events" ADD CONSTRAINT "container_events_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "environment_notifications" ADD CONSTRAINT "environment_notifications_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "environment_notifications" ADD CONSTRAINT "environment_notifications_notification_id_notification_settings_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notification_settings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_repositories" ADD CONSTRAINT "git_repositories_credential_id_git_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."git_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD CONSTRAINT "git_stacks_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD CONSTRAINT "git_stacks_repository_id_git_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."git_repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hawser_tokens" ADD CONSTRAINT "hawser_tokens_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_metrics" ADD CONSTRAINT "host_metrics_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule_executions" ADD CONSTRAINT "schedule_executions_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stack_events" ADD CONSTRAINT "stack_events_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stack_sources" ADD CONSTRAINT "stack_sources_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stack_sources" ADD CONSTRAINT "stack_sources_git_repository_id_git_repositories_id_fk" FOREIGN KEY ("git_repository_id") REFERENCES "public"."git_repositories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stack_sources" ADD CONSTRAINT "stack_sources_git_stack_id_git_stacks_id_fk" FOREIGN KEY ("git_stack_id") REFERENCES "public"."git_stacks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vulnerability_scans" ADD CONSTRAINT "vulnerability_scans_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "container_events_env_timestamp_idx" ON "container_events" USING btree ("environment_id","timestamp");--> statement-breakpoint
CREATE INDEX "host_metrics_env_timestamp_idx" ON "host_metrics" USING btree ("environment_id","timestamp");--> statement-breakpoint
CREATE INDEX "schedule_executions_type_id_idx" ON "schedule_executions" USING btree ("schedule_type","schedule_id");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "vulnerability_scans_env_image_idx" ON "vulnerability_scans" USING btree ("environment_id","image_id");
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "stack_environment_variables" (
"id" serial PRIMARY KEY NOT NULL,
"stack_name" text NOT NULL,
"environment_id" integer,
"key" text NOT NULL,
"value" text NOT NULL,
"is_secret" boolean DEFAULT false,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "stack_environment_variables_stack_name_environment_id_key_unique" UNIQUE("stack_name","environment_id","key")
);
--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "env_file_path" text;--> statement-breakpoint
ALTER TABLE "stack_environment_variables" ADD CONSTRAINT "stack_environment_variables_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,12 @@
CREATE TABLE "pending_container_updates" (
"id" serial PRIMARY KEY NOT NULL,
"environment_id" integer NOT NULL,
"container_id" text NOT NULL,
"container_name" text NOT NULL,
"current_image" text NOT NULL,
"checked_at" timestamp DEFAULT now(),
"created_at" timestamp DEFAULT now(),
CONSTRAINT "pending_container_updates_environment_id_container_id_unique" UNIQUE("environment_id","container_id")
);
--> statement-breakpoint
ALTER TABLE "pending_container_updates" ADD CONSTRAINT "pending_container_updates_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "stack_sources" ADD COLUMN "compose_path" text;--> statement-breakpoint
ALTER TABLE "stack_sources" ADD COLUMN "env_path" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1765804022462,
"tag": "0000_initial_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1766378770502,
"tag": "0001_add_stack_env_vars",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1766763867484,
"tag": "0002_add_pending_container_updates",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767687362730,
"tag": "0003_add_stack_paths",
"breakpoints": true
}
]
}
+401
View File
@@ -0,0 +1,401 @@
CREATE TABLE `audit_logs` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer,
`username` text NOT NULL,
`action` text NOT NULL,
`entity_type` text NOT NULL,
`entity_id` text,
`entity_name` text,
`environment_id` integer,
`description` text,
`details` text,
`ip_address` text,
`user_agent` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `audit_logs_user_id_idx` ON `audit_logs` (`user_id`);--> statement-breakpoint
CREATE INDEX `audit_logs_created_at_idx` ON `audit_logs` (`created_at`);--> statement-breakpoint
CREATE TABLE `auth_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`auth_enabled` integer DEFAULT false,
`default_provider` text DEFAULT 'local',
`session_timeout` integer DEFAULT 86400,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `auto_update_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer,
`container_name` text NOT NULL,
`enabled` integer DEFAULT false,
`schedule_type` text DEFAULT 'daily',
`cron_expression` text,
`vulnerability_criteria` text DEFAULT 'never',
`last_checked` text,
`last_updated` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `auto_update_settings_environment_id_container_name_unique` ON `auto_update_settings` (`environment_id`,`container_name`);--> statement-breakpoint
CREATE TABLE `config_sets` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`env_vars` text,
`labels` text,
`ports` text,
`volumes` text,
`network_mode` text DEFAULT 'bridge',
`restart_policy` text DEFAULT 'no',
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `config_sets_name_unique` ON `config_sets` (`name`);--> statement-breakpoint
CREATE TABLE `container_events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer,
`container_id` text NOT NULL,
`container_name` text,
`image` text,
`action` text NOT NULL,
`actor_attributes` text,
`timestamp` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `container_events_env_timestamp_idx` ON `container_events` (`environment_id`,`timestamp`);--> statement-breakpoint
CREATE TABLE `environment_notifications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer NOT NULL,
`notification_id` integer NOT NULL,
`enabled` integer DEFAULT true,
`event_types` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`notification_id`) REFERENCES `notification_settings`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `environment_notifications_environment_id_notification_id_unique` ON `environment_notifications` (`environment_id`,`notification_id`);--> statement-breakpoint
CREATE TABLE `environments` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`host` text,
`port` integer DEFAULT 2375,
`protocol` text DEFAULT 'http',
`tls_ca` text,
`tls_cert` text,
`tls_key` text,
`tls_skip_verify` integer DEFAULT false,
`icon` text DEFAULT 'globe',
`collect_activity` integer DEFAULT true,
`collect_metrics` integer DEFAULT true,
`highlight_changes` integer DEFAULT true,
`labels` text,
`connection_type` text DEFAULT 'socket',
`socket_path` text DEFAULT '/var/run/docker.sock',
`hawser_token` text,
`hawser_last_seen` text,
`hawser_agent_id` text,
`hawser_agent_name` text,
`hawser_version` text,
`hawser_capabilities` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `environments_name_unique` ON `environments` (`name`);--> statement-breakpoint
CREATE TABLE `git_credentials` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`auth_type` text DEFAULT 'none' NOT NULL,
`username` text,
`password` text,
`ssh_private_key` text,
`ssh_passphrase` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `git_credentials_name_unique` ON `git_credentials` (`name`);--> statement-breakpoint
CREATE TABLE `git_repositories` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`branch` text DEFAULT 'main',
`credential_id` integer,
`compose_path` text DEFAULT 'docker-compose.yml',
`environment_id` integer,
`auto_update` integer DEFAULT false,
`auto_update_schedule` text DEFAULT 'daily',
`auto_update_cron` text DEFAULT '0 3 * * *',
`webhook_enabled` integer DEFAULT false,
`webhook_secret` text,
`last_sync` text,
`last_commit` text,
`sync_status` text DEFAULT 'pending',
`sync_error` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`credential_id`) REFERENCES `git_credentials`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `git_repositories_name_unique` ON `git_repositories` (`name`);--> statement-breakpoint
CREATE TABLE `git_stacks` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`stack_name` text NOT NULL,
`environment_id` integer,
`repository_id` integer NOT NULL,
`compose_path` text DEFAULT 'docker-compose.yml',
`auto_update` integer DEFAULT false,
`auto_update_schedule` text DEFAULT 'daily',
`auto_update_cron` text DEFAULT '0 3 * * *',
`webhook_enabled` integer DEFAULT false,
`webhook_secret` text,
`last_sync` text,
`last_commit` text,
`sync_status` text DEFAULT 'pending',
`sync_error` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`repository_id`) REFERENCES `git_repositories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `git_stacks_stack_name_environment_id_unique` ON `git_stacks` (`stack_name`,`environment_id`);--> statement-breakpoint
CREATE TABLE `hawser_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`token` text NOT NULL,
`token_prefix` text NOT NULL,
`name` text NOT NULL,
`environment_id` integer,
`is_active` integer DEFAULT true,
`last_used` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`expires_at` text,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `hawser_tokens_token_unique` ON `hawser_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `host_metrics` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer,
`cpu_percent` real NOT NULL,
`memory_percent` real NOT NULL,
`memory_used` integer,
`memory_total` integer,
`timestamp` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `host_metrics_env_timestamp_idx` ON `host_metrics` (`environment_id`,`timestamp`);--> statement-breakpoint
CREATE TABLE `ldap_config` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT false,
`server_url` text NOT NULL,
`bind_dn` text,
`bind_password` text,
`base_dn` text NOT NULL,
`user_filter` text DEFAULT '(uid={{username}})',
`username_attribute` text DEFAULT 'uid',
`email_attribute` text DEFAULT 'mail',
`display_name_attribute` text DEFAULT 'cn',
`group_base_dn` text,
`group_filter` text,
`admin_group` text,
`role_mappings` text,
`tls_enabled` integer DEFAULT false,
`tls_ca` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `notification_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`type` text NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT true,
`config` text NOT NULL,
`event_types` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `oidc_config` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT false,
`issuer_url` text NOT NULL,
`client_id` text NOT NULL,
`client_secret` text NOT NULL,
`redirect_uri` text NOT NULL,
`scopes` text DEFAULT 'openid profile email',
`username_claim` text DEFAULT 'preferred_username',
`email_claim` text DEFAULT 'email',
`display_name_claim` text DEFAULT 'name',
`admin_claim` text,
`admin_value` text,
`role_mappings_claim` text DEFAULT 'groups',
`role_mappings` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `registries` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`username` text,
`password` text,
`is_default` integer DEFAULT false,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `registries_name_unique` ON `registries` (`name`);--> statement-breakpoint
CREATE TABLE `roles` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`is_system` integer DEFAULT false,
`permissions` text NOT NULL,
`environment_ids` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);--> statement-breakpoint
CREATE TABLE `schedule_executions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`schedule_type` text NOT NULL,
`schedule_id` integer NOT NULL,
`environment_id` integer,
`entity_name` text NOT NULL,
`triggered_by` text NOT NULL,
`triggered_at` text NOT NULL,
`started_at` text,
`completed_at` text,
`duration` integer,
`status` text NOT NULL,
`error_message` text,
`details` text,
`logs` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `schedule_executions_type_id_idx` ON `schedule_executions` (`schedule_type`,`schedule_id`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`provider` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `sessions_expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE TABLE `stack_events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer,
`stack_name` text NOT NULL,
`event_type` text NOT NULL,
`timestamp` text DEFAULT CURRENT_TIMESTAMP,
`metadata` text,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `stack_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`stack_name` text NOT NULL,
`environment_id` integer,
`source_type` text DEFAULT 'internal' NOT NULL,
`git_repository_id` integer,
`git_stack_id` integer,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`git_repository_id`) REFERENCES `git_repositories`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`git_stack_id`) REFERENCES `git_stacks`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `stack_sources_stack_name_environment_id_unique` ON `stack_sources` (`stack_name`,`environment_id`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer,
`environment_id` integer,
`key` text NOT NULL,
`value` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_preferences_user_id_environment_id_key_unique` ON `user_preferences` (`user_id`,`environment_id`,`key`);--> statement-breakpoint
CREATE TABLE `user_roles` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`role_id` integer NOT NULL,
`environment_id` integer,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_roles_user_id_role_id_environment_id_unique` ON `user_roles` (`user_id`,`role_id`,`environment_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`email` text,
`password_hash` text NOT NULL,
`display_name` text,
`avatar` text,
`auth_provider` text DEFAULT 'local',
`mfa_enabled` integer DEFAULT false,
`mfa_secret` text,
`is_active` integer DEFAULT true,
`last_login` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
CREATE TABLE `vulnerability_scans` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer,
`image_id` text NOT NULL,
`image_name` text NOT NULL,
`scanner` text NOT NULL,
`scanned_at` text NOT NULL,
`scan_duration` integer,
`critical_count` integer DEFAULT 0,
`high_count` integer DEFAULT 0,
`medium_count` integer DEFAULT 0,
`low_count` integer DEFAULT 0,
`negligible_count` integer DEFAULT 0,
`unknown_count` integer DEFAULT 0,
`vulnerabilities` text,
`error` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `vulnerability_scans_env_image_idx` ON `vulnerability_scans` (`environment_id`,`image_id`);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `stack_environment_variables` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`stack_name` text NOT NULL,
`environment_id` integer,
`key` text NOT NULL,
`value` text NOT NULL,
`is_secret` integer DEFAULT false,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `stack_environment_variables_stack_name_environment_id_key_unique` ON `stack_environment_variables` (`stack_name`,`environment_id`,`key`);--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `env_file_path` text;
@@ -0,0 +1,12 @@
CREATE TABLE `pending_container_updates` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`environment_id` integer NOT NULL,
`container_id` text NOT NULL,
`container_name` text NOT NULL,
`current_image` text NOT NULL,
`checked_at` text DEFAULT CURRENT_TIMESTAMP,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`environment_id`) REFERENCES `environments`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `pending_container_updates_environment_id_container_id_unique` ON `pending_container_updates` (`environment_id`,`container_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `stack_sources` ADD `compose_path` text;--> statement-breakpoint
ALTER TABLE `stack_sources` ADD `env_path` text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1765804016391,
"tag": "0000_initial_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1766378754939,
"tag": "0001_add_stack_env_vars",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1766763860091,
"tag": "0002_add_pending_container_updates",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1767689000000,
"tag": "0003_add_stack_paths",
"breakpoints": true
}
]
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+38 -30
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.4",
"version": "1.0.8",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
@@ -39,7 +39,7 @@
},
"dependencies": {
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.0",
"@codemirror/commands": "6.10.1",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.4",
@@ -48,63 +48,71 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.11.3",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.3",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.9",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"codemirror": "6.0.2",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"drizzle-orm": "0.45.0",
"drizzle-orm": "0.45.1",
"hash-wasm": "4.12.0",
"js-yaml": "^4.1.1",
"ldapts": "^8.0.9",
"nodemailer": "^7.0.11",
"ldapts": "^8.1.3",
"nodemailer": "^7.0.12",
"otpauth": "^9.4.1",
"postgres": "3.4.7",
"postgres": "3.4.8",
"qrcode": "^1.5.4",
"svelte-dnd-action": "0.9.68",
"svelte-dnd-action": "0.9.69",
"svelte-sonner": "1.0.7"
},
"devDependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.8",
"@internationalized/date": "^3.10.0",
"@internationalized/date": "^3.10.1",
"@layerstack/tailwind": "^1.0.1",
"@lucide/svelte": "^0.544.0",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "1.57.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/bun": "^1.2.5",
"@sveltejs/kit": "^2.49.3",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.5",
"@types/js-yaml": "^4.0.9",
"@types/nodemailer": "^7.0.4",
"@types/qrcode": "^1.5.6",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10.4.22",
"bits-ui": "^2.14.4",
"@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",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.12",
"lucide-svelte": "^0.555.0",
"layerchart": "^1.0.13",
"lucide-svelte": "^0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "^5.43.8",
"svelte": "^5.46.1",
"svelte-adapter-bun": "1.0.1",
"svelte-check": "^4.3.4",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.2"
"vite": "^7.3.1"
},
"overrides": {
"@codemirror/state": "6.5.3",
"@codemirror/view": "6.39.9",
"@codemirror/language": "6.12.1",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 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');
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
#
# Emergency script to backup the database
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/backup-db.sh [output_dir]
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/backup-db.sh /app/data/backups
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/backup-db.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/backup-db.sh" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
#
# Emergency script to clear all user sessions
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/clear-sessions.sh
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/clear-sessions.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/clear-sessions.sh" "$@"
fi
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
#
# Emergency script to create an admin user
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/create-admin.sh
#
# Default credentials: admin / admin123
# CHANGE THE PASSWORD IMMEDIATELY after logging in!
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/create-admin.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/create-admin.sh" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
#
# Emergency script to disable authentication
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/disable-auth.sh
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/disable-auth.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/disable-auth.sh" "$@"
fi
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
#
# Emergency script to export all compose stacks
# Exports docker-compose.yml files from the stacks directory
#
# Usage:
# docker exec -it dockhand /app/scripts/export-stacks.sh [output_dir]
#
# Example:
# docker exec -it dockhand /app/scripts/export-stacks.sh /tmp/stacks-backup
#
# Default output: /app/data/stacks-export
#
set -e
echo "========================================"
echo " Dockhand - Export Compose Stacks"
echo "========================================"
echo ""
# Default paths
STACKS_DIR="${DOCKHAND_STACKS:-/home/dockhand/.dockhand/stacks}"
OUTPUT_DIR="${1:-/app/data/stacks-export}"
# Check if running locally (not in Docker)
if [ ! -d "$STACKS_DIR" ] && [ -d "$HOME/.dockhand/stacks" ]; then
STACKS_DIR="$HOME/.dockhand/stacks"
fi
if [ ! -d "$STACKS_DIR" ]; then
echo "Error: Stacks directory not found at $STACKS_DIR"
exit 1
fi
# Count stacks
STACK_COUNT=$(find "$STACKS_DIR" -maxdepth 1 -type d ! -path "$STACKS_DIR" 2>/dev/null | wc -l | tr -d ' ')
echo "This script will export all compose stacks."
echo ""
echo "Stacks directory: $STACKS_DIR"
echo "Output directory: $OUTPUT_DIR"
echo "Stacks found: $STACK_COUNT"
echo ""
if [ "$STACK_COUNT" -eq "0" ]; then
echo "No stacks found to export."
exit 0
fi
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
# Create output directory
mkdir -p "$OUTPUT_DIR"
echo "Exporting stacks..."
echo ""
# Export each stack
find "$STACKS_DIR" -maxdepth 1 -type d ! -path "$STACKS_DIR" | while read stack_dir; do
STACK_NAME=$(basename "$stack_dir")
COMPOSE_FILE="$stack_dir/docker-compose.yml"
if [ -f "$COMPOSE_FILE" ]; then
mkdir -p "$OUTPUT_DIR/$STACK_NAME"
cp "$COMPOSE_FILE" "$OUTPUT_DIR/$STACK_NAME/"
# Also copy .env file if exists
if [ -f "$stack_dir/.env" ]; then
cp "$stack_dir/.env" "$OUTPUT_DIR/$STACK_NAME/"
fi
echo " Exported: $STACK_NAME"
fi
done
echo ""
echo "Export complete!"
echo "Stacks exported to: $OUTPUT_DIR"
echo ""
echo "To copy from Docker container to host:"
echo " docker cp dockhand:$OUTPUT_DIR ./stacks-backup"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
#
# Emergency script to list all users
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/list-users.sh
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/list-users.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/list-users.sh" "$@"
fi
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to backup the database
# Creates a timestamped dump of the database
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/backup-db.sh [output_dir]
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/postgres/backup-db.sh /app/data/backups
#
# Default output: /app/data
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Backup Database (PostgreSQL)"
echo "========================================"
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
OUTPUT_DIR="${1:-/app/data}"
# Parse DATABASE_URL
# Format: postgres://user:password@host:port/database
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
# Extract credentials
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
# Generate backup filename with timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$OUTPUT_DIR/dockhand_backup_$TIMESTAMP.sql"
echo "This script will create a backup of the database."
echo ""
echo "Host: $DB_HOST:$DB_PORT"
echo "Database: $DB_NAME"
echo "Backup: $BACKUP_FILE"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
# Create output directory if needed
mkdir -p "$OUTPUT_DIR"
echo "Creating database backup..."
# Use pg_dump to create backup
export PGPASSWORD="$DB_PASS"
if command -v pg_dump >/dev/null 2>&1; then
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$BACKUP_FILE"
else
echo "Error: pg_dump not found"
echo "Install PostgreSQL client tools to use this script"
exit 1
fi
if [ $? -eq 0 ] && [ -f "$BACKUP_FILE" ]; then
SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}')
echo ""
echo "Backup created successfully!"
echo "Size: $SIZE"
echo ""
echo "To copy from Docker container to host:"
echo " docker cp dockhand:$BACKUP_FILE ./dockhand_backup_$TIMESTAMP.sql"
else
echo "Error: Failed to create backup"
exit 1
fi
+75
View File
@@ -0,0 +1,75 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to clear all user sessions
# Use this to force all users to re-login
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/clear-sessions.sh
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Clear All Sessions (PostgreSQL)"
echo "========================================"
echo ""
echo "This script will clear all user sessions,"
echo "forcing all users to log in again."
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM sessions;" 2>/dev/null | tr -d ' ')
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo "Active sessions: $COUNT"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Clearing all user sessions..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DELETE FROM sessions;"
if [ $? -eq 0 ]; then
echo ""
echo "Cleared $COUNT session(s) successfully."
echo "All users will need to log in again."
else
echo "Error: Failed to clear sessions"
exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to create an admin user
# Use this if you're locked out of Dockhand and need to create a new admin
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/create-admin.sh
#
# Default credentials: admin / admin123
# CHANGE THE PASSWORD IMMEDIATELY after logging in!
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Create Admin User (PostgreSQL)"
echo "========================================"
echo ""
echo "This script will create an admin user with:"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo "If user 'admin' already exists, password will"
echo "be reset and admin privileges restored."
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Username and password
USERNAME="admin"
# Password: admin123
# This is an argon2id hash of "admin123" - generated with default argon2 settings
PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Jq4am2SfyYKmc0PAHe+yzg$cq/27vK/Qg2eZb/jMDy0ExLDhOG+58cKAximxpG5Dss'
echo ""
echo "Creating admin user..."
# Check if admin user already exists
EXISTING=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ')
if [ "$EXISTING" -gt "0" ]; then
echo "User '$USERNAME' already exists."
echo "Resetting password and ensuring active status..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE users SET password_hash='$PASSWORD_HASH', is_active=true WHERE username='$USERNAME';"
USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ')
else
echo "Creating new admin user..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO users (username, password_hash, is_active, auth_provider, created_at, updated_at) VALUES ('$USERNAME', '$PASSWORD_HASH', true, 'local', NOW(), NOW());"
USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ')
echo "Admin user created successfully."
fi
# Get the Admin role ID (it's a system role)
ADMIN_ROLE_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null | tr -d ' ')
if [ -z "$ADMIN_ROLE_ID" ]; then
echo "Warning: Admin role not found in database."
echo "The user was created but may not have admin privileges."
echo "Please check Settings > Auth > Roles after logging in."
else
# Check if user already has Admin role
HAS_ROLE=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM user_roles WHERE user_id=$USER_ID AND role_id=$ADMIN_ROLE_ID;" 2>/dev/null | tr -d ' ')
if [ "$HAS_ROLE" -eq "0" ]; then
echo "Assigning Admin role..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($USER_ID, $ADMIN_ROLE_ID, NOW());"
echo "Admin role assigned."
else
echo "User already has Admin role."
fi
fi
echo ""
echo "Credentials:"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo "WARNING: Change the password immediately after logging in!"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to disable authentication
# Use this if you're locked out of Dockhand
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/disable-auth.sh
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Disable Authentication (PostgreSQL)"
echo "========================================"
echo ""
echo "This script will disable authentication,"
echo "allowing access to Dockhand without login."
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Disabling authentication..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE auth_settings SET auth_enabled = false WHERE id = 1;"
if [ $? -eq 0 ]; then
echo ""
echo "Authentication disabled successfully."
echo "You can now access Dockhand without logging in."
echo ""
echo "Remember to re-enable authentication in Settings after regaining access."
else
echo "Error: Failed to disable authentication"
exit 1
fi
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to list all users
# Shows username, admin status, active status, and last login
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/list-users.sh
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - List Users (PostgreSQL)"
echo "========================================"
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
# Get user count
USER_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users;" 2>/dev/null | tr -d ' ')
if [ "$USER_COUNT" -eq "0" ]; then
echo "No users found."
exit 0
fi
# Get Admin role ID for checking admin status
ADMIN_ROLE_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null | tr -d ' ')
# Print header
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "ID" "Username" "Admin" "Active" "MFA" "Last Login"
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "----" "--------------------" "--------" "--------" "------" "-------------------"
# List users (check admin status via user_roles table)
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "SELECT id, username, is_active, mfa_enabled, COALESCE(last_login::text, 'Never') FROM users ORDER BY id;" 2>/dev/null | while IFS='|' read id username is_active mfa_enabled last_login; do
# Check if user has Admin role
if [ -n "$ADMIN_ROLE_ID" ]; then
HAS_ADMIN=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM user_roles WHERE user_id=$id AND role_id=$ADMIN_ROLE_ID;" 2>/dev/null | tr -d ' ')
if [ "$HAS_ADMIN" -gt "0" ]; then
admin_str="Yes"
else
admin_str="No"
fi
else
admin_str="N/A"
fi
# Convert boolean values (PostgreSQL returns t/f)
if [ "$is_active" = "t" ]; then
active_str="Yes"
else
active_str="No"
fi
if [ "$mfa_enabled" = "t" ]; then
mfa_str="Yes"
else
mfa_str="No"
fi
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "$id" "$username" "$admin_str" "$active_str" "$mfa_str" "$last_login"
done
echo ""
echo "Total: $USER_COUNT user(s)"
# Show session count
SESSION_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM sessions;" 2>/dev/null | tr -d ' ')
echo "Active sessions: $SESSION_COUNT"
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to factory reset the database
# WARNING: This will DELETE ALL DATA including users, settings, and activity logs!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/reset-db.sh
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Factory Reset Database (PostgreSQL)"
echo "========================================"
echo ""
echo "WARNING: This will DELETE ALL DATA!"
echo ""
echo "This includes:"
echo " - All users and their settings"
echo " - All sessions"
echo " - Authentication settings"
echo " - Activity logs"
echo " - Environment configurations"
echo " - OIDC/SSO settings"
echo ""
echo "The database tables will be truncated."
echo ""
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Creating backup before reset..."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/app/data/dockhand_backup_pre_reset_$TIMESTAMP.sql"
if command -v pg_dump >/dev/null 2>&1; then
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$BACKUP_FILE" 2>/dev/null || true
if [ -f "$BACKUP_FILE" ]; then
echo "Backup saved to: $BACKUP_FILE"
fi
fi
echo ""
echo "Truncating all tables..."
# Truncate all tables in the correct order (respecting foreign keys)
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
TRUNCATE TABLE
sessions,
user_roles,
dashboard_preferences,
audit_logs,
container_events,
vulnerability_scans,
stack_sources,
git_stacks,
git_repositories,
git_credentials,
host_metrics,
stack_events,
environment_notifications,
auto_update_settings,
users,
roles,
oidc_config,
ldap_config,
auth_settings,
notification_settings,
config_sets,
registries,
environments,
settings
CASCADE;
EOF
echo ""
echo "Database reset successfully."
echo ""
echo "Restart Dockhand to recreate default data:"
echo " docker restart dockhand"
+139
View File
@@ -0,0 +1,139 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to reset a user's password
# Use this if a user is locked out and needs a password reset
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/reset-password.sh <username> <new_password>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/postgres/reset-password.sh admin MyNewPassword123
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Reset User Password (PostgreSQL)"
echo "========================================"
echo ""
# Check arguments
if [ -z "$1" ] || [ -z "$2" ]; then
echo "Usage: $0 <username> <new_password>"
echo ""
echo "Example:"
echo " $0 admin MyNewPassword123"
exit 1
fi
USERNAME="$1"
NEW_PASSWORD="$2"
# Validate password length
if [ ${#NEW_PASSWORD} -lt 8 ]; then
echo "Error: Password must be at least 8 characters"
exit 1
fi
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
# Check if user exists
EXISTING=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ')
if [ "$EXISTING" -eq "0" ]; then
echo "Error: User '$USERNAME' not found"
echo ""
echo "Available users:"
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT username FROM users;" 2>/dev/null | while read user; do
user=$(echo "$user" | tr -d ' ')
if [ -n "$user" ]; then
echo " - $user"
fi
done
exit 1
fi
echo "This script will reset the password for user '$USERNAME'."
echo ""
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo "Username: $USERNAME"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Generate password hash using node (argon2 is available in the app)
echo ""
echo "Generating password hash..."
# Check if node and argon2 are available
if command -v node >/dev/null 2>&1; then
# Try to use argon2 from node_modules
PASSWORD_HASH=$(node -e "
try {
const argon2 = require('argon2');
argon2.hash('$NEW_PASSWORD').then(h => console.log(h)).catch(e => process.exit(1));
} catch(e) {
process.exit(1);
}
" 2>/dev/null)
if [ -z "$PASSWORD_HASH" ]; then
echo "Error: Could not generate password hash (argon2 not available)"
echo "This script requires Node.js with argon2 module"
exit 1
fi
else
echo "Error: Node.js is required to generate password hash"
exit 1
fi
echo "Resetting password for user '$USERNAME'..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE users SET password_hash='$PASSWORD_HASH', updated_at=NOW() WHERE username='$USERNAME';"
if [ $? -eq 0 ]; then
echo ""
echo "Password reset successfully for user '$USERNAME'"
echo ""
# Invalidate sessions
USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ')
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DELETE FROM sessions WHERE user_id=$USER_ID;" 2>/dev/null || true
echo "All existing sessions have been invalidated."
echo "The user can now log in with the new password."
else
echo "Error: Failed to reset password"
exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
#!/bin/sh
#
# PostgreSQL: Emergency script to restore the database from a backup
# WARNING: This will overwrite the current database!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/postgres/restore-db.sh <backup_file>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/postgres/restore-db.sh /app/data/dockhand_backup_20240115_120000.sql
#
# To copy backup into container first:
# docker cp ./dockhand_backup.sql dockhand:/app/data/
#
# Requires: DATABASE_URL environment variable
#
set -e
echo "========================================"
echo " Dockhand - Restore Database (PostgreSQL)"
echo "========================================"
echo ""
# Check argument
if [ -z "$1" ]; then
echo "Usage: $0 <backup_file>"
echo ""
echo "Example:"
echo " $0 /app/data/dockhand_backup_20240115_120000.sql"
echo ""
echo "To copy backup into container first:"
echo " docker cp ./dockhand_backup.sql dockhand:/app/data/"
exit 1
fi
BACKUP_FILE="$1"
# Check DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
echo "Error: DATABASE_URL environment variable not set"
echo ""
echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand"
exit 1
fi
# Parse DATABASE_URL
DB_URL="$DATABASE_URL"
DB_URL="${DB_URL#postgres://}"
DB_URL="${DB_URL#postgresql://}"
DB_USER="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PASS="${DB_URL%%@*}"
DB_URL="${DB_URL#*@}"
DB_HOST="${DB_URL%%:*}"
DB_URL="${DB_URL#*:}"
DB_PORT="${DB_URL%%/*}"
DB_NAME="${DB_URL#*/}"
DB_NAME="${DB_NAME%%\?*}"
export PGPASSWORD="$DB_PASS"
# Check if backup file exists
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
# Get backup file size
BACKUP_SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}')
echo "WARNING: This will overwrite the current database!"
echo ""
echo "Database: $DB_HOST:$DB_PORT/$DB_NAME"
echo "Backup to restore: $BACKUP_FILE ($BACKUP_SIZE)"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Create backup of current database before restoring
echo ""
echo "Creating backup of current database..."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
PRE_RESTORE_BACKUP="/app/data/dockhand_pre_restore_$TIMESTAMP.sql"
if command -v pg_dump >/dev/null 2>&1; then
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$PRE_RESTORE_BACKUP" 2>/dev/null || true
if [ -f "$PRE_RESTORE_BACKUP" ]; then
echo "Current database backed up to: $PRE_RESTORE_BACKUP"
fi
fi
echo ""
echo "Restoring database..."
# Drop and recreate all tables by running the backup
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$BACKUP_FILE"
if [ $? -eq 0 ]; then
echo ""
echo "Database restored successfully!"
echo ""
echo "Restart Dockhand to apply changes:"
echo " docker restart dockhand"
else
echo "Error: Failed to restore database"
exit 1
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
#
# Emergency script to factory reset the database
# Automatically detects database type (SQLite or PostgreSQL)
# WARNING: This will DELETE ALL DATA!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/reset-db.sh
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/reset-db.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/reset-db.sh" "$@"
fi
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
#
# Emergency script to reset a user's password
# Automatically detects database type (SQLite or PostgreSQL)
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/reset-password.sh <username> <new_password>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/reset-password.sh admin MyNewPassword123
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/reset-password.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/reset-password.sh" "$@"
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
#
# Emergency script to restore the database from a backup
# Automatically detects database type (SQLite or PostgreSQL)
# WARNING: This will overwrite the current database!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/restore-db.sh <backup_file>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/restore-db.sh /app/data/dockhand_backup_20240115_120000.db
#
SCRIPT_DIR="$(dirname "$0")"
# Detect database type
if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then
exec "$SCRIPT_DIR/postgres/restore-db.sh" "$@"
else
exec "$SCRIPT_DIR/sqlite/restore-db.sh" "$@"
fi
+88
View File
@@ -0,0 +1,88 @@
#!/bin/sh
#
# SQLite: Emergency script to backup the database
# Creates a timestamped copy of the database file
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/backup-db.sh [output_dir]
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/sqlite/backup-db.sh /app/data/backups
#
# Default output: /app/data (same directory as database)
#
set -e
echo "========================================"
echo " Dockhand - Backup Database (SQLite)"
echo "========================================"
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
OUTPUT_DIR="${1:-$(dirname "$DB_PATH")}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
OUTPUT_DIR="${1:-./data/db}"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
# Generate backup filename with timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$OUTPUT_DIR/dockhand_backup_$TIMESTAMP.db"
# Get database size
DB_SIZE=$(ls -lh "$DB_PATH" | awk '{print $5}')
echo "This script will create a backup of the database."
echo ""
echo "Source: $DB_PATH ($DB_SIZE)"
echo "Backup: $BACKUP_FILE"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
# Create output directory if needed
mkdir -p "$OUTPUT_DIR"
echo "Creating database backup..."
# Use sqlite3 backup command for safe backup (handles WAL mode)
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'"
else
# Fallback to file copy if sqlite3 not available
cp "$DB_PATH" "$BACKUP_FILE"
fi
if [ $? -eq 0 ] && [ -f "$BACKUP_FILE" ]; then
SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}')
echo ""
echo "Backup created successfully!"
echo "Size: $SIZE"
echo ""
echo "To copy from Docker container to host:"
echo " docker cp dockhand:$BACKUP_FILE ./dockhand_backup_$TIMESTAMP.db"
else
echo "Error: Failed to create backup"
exit 1
fi
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
#
# SQLite: Emergency script to clear all user sessions
# Use this to force all users to re-login
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/clear-sessions.sh
#
set -e
echo "========================================"
echo " Dockhand - Clear All Sessions (SQLite)"
echo "========================================"
echo ""
echo "This script will clear all user sessions,"
echo "forcing all users to log in again."
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions;")
echo "Database: $DB_PATH"
echo "Active sessions: $COUNT"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Clearing all user sessions..."
sqlite3 "$DB_PATH" "DELETE FROM sessions;"
if [ $? -eq 0 ]; then
echo ""
echo "Cleared $COUNT session(s) successfully."
echo "All users will need to log in again."
else
echo "Error: Failed to clear sessions"
exit 1
fi
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
#
# SQLite: Emergency script to create an admin user
# Use this if you're locked out of Dockhand and need to create a new admin
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/create-admin.sh
#
# Default credentials: admin / admin123
# CHANGE THE PASSWORD IMMEDIATELY after logging in!
#
set -e
echo "========================================"
echo " Dockhand - Create Admin User (SQLite)"
echo "========================================"
echo ""
echo "This script will create an admin user with:"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo "If user 'admin' already exists, password will"
echo "be reset and admin privileges restored."
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
echo "Database: $DB_PATH"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Username and password
USERNAME="admin"
# Password: admin123
# This is an argon2id hash of "admin123" - generated with default argon2 settings
PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Jq4am2SfyYKmc0PAHe+yzg$cq/27vK/Qg2eZb/jMDy0ExLDhOG+58cKAximxpG5Dss'
echo ""
echo "Creating admin user..."
# Check if admin user already exists
EXISTING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users WHERE username='$USERNAME';")
if [ "$EXISTING" -gt "0" ]; then
echo "User '$USERNAME' already exists."
echo "Resetting password and ensuring active status..."
sqlite3 "$DB_PATH" "UPDATE users SET password_hash='$PASSWORD_HASH', is_active=1 WHERE username='$USERNAME';"
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';")
else
echo "Creating new admin user..."
sqlite3 "$DB_PATH" "INSERT INTO users (username, password_hash, is_active, auth_provider, created_at, updated_at) VALUES ('$USERNAME', '$PASSWORD_HASH', 1, 'local', datetime('now'), datetime('now'));"
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';")
echo "Admin user created successfully."
fi
# Get the Admin role ID (it's a system role)
ADMIN_ROLE_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM roles WHERE name='Admin';")
if [ -z "$ADMIN_ROLE_ID" ]; then
echo "Warning: Admin role not found in database."
echo "The user was created but may not have admin privileges."
echo "Please check Settings > Auth > Roles after logging in."
else
# Check if user already has Admin role
HAS_ROLE=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM user_roles WHERE user_id=$USER_ID AND role_id=$ADMIN_ROLE_ID;")
if [ "$HAS_ROLE" -eq "0" ]; then
echo "Assigning Admin role..."
sqlite3 "$DB_PATH" "INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($USER_ID, $ADMIN_ROLE_ID, datetime('now'));"
echo "Admin role assigned."
else
echo "User already has Admin role."
fi
fi
echo ""
echo "Credentials:"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo "WARNING: Change the password immediately after logging in!"
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
#
# SQLite: Emergency script to disable authentication
# Use this if you're locked out of Dockhand
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/disable-auth.sh
#
set -e
echo "========================================"
echo " Dockhand - Disable Authentication (SQLite)"
echo "========================================"
echo ""
echo "This script will disable authentication,"
echo "allowing access to Dockhand without login."
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
echo "Database: $DB_PATH"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Disabling authentication..."
sqlite3 "$DB_PATH" "UPDATE auth_settings SET auth_enabled = 0 WHERE id = 1;"
if [ $? -eq 0 ]; then
echo ""
echo "Authentication disabled successfully."
echo "You can now access Dockhand without logging in."
echo ""
echo "Remember to re-enable authentication in Settings after regaining access."
else
echo "Error: Failed to disable authentication"
exit 1
fi
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
#
# SQLite: Emergency script to list all users
# Shows username, admin status, active status, and last login
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/list-users.sh
#
set -e
echo "========================================"
echo " Dockhand - List Users (SQLite)"
echo "========================================"
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
# Get user count
USER_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users;")
if [ "$USER_COUNT" -eq "0" ]; then
echo "No users found."
exit 0
fi
# Get Admin role ID for checking admin status
ADMIN_ROLE_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null || echo "")
# Print header
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "ID" "Username" "Admin" "Active" "MFA" "Last Login"
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "----" "--------------------" "--------" "--------" "------" "-------------------"
# List users (check admin status via user_roles table)
sqlite3 -separator '|' "$DB_PATH" "SELECT id, username, is_active, mfa_enabled, COALESCE(last_login, 'Never') FROM users ORDER BY id;" | while IFS='|' read id username is_active mfa_enabled last_login; do
# Check if user has Admin role
if [ -n "$ADMIN_ROLE_ID" ]; then
HAS_ADMIN=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM user_roles WHERE user_id=$id AND role_id=$ADMIN_ROLE_ID;")
if [ "$HAS_ADMIN" -gt "0" ]; then
admin_str="Yes"
else
admin_str="No"
fi
else
admin_str="N/A"
fi
if [ "$is_active" = "1" ]; then
active_str="Yes"
else
active_str="No"
fi
if [ "$mfa_enabled" = "1" ]; then
mfa_str="Yes"
else
mfa_str="No"
fi
printf "%-4s %-20s %-8s %-8s %-6s %s\n" "$id" "$username" "$admin_str" "$active_str" "$mfa_str" "$last_login"
done
echo ""
echo "Total: $USER_COUNT user(s)"
# Show session count
SESSION_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions;")
echo "Active sessions: $SESSION_COUNT"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
#
# SQLite: Emergency script to factory reset the database
# WARNING: This will DELETE ALL DATA including users, settings, and activity logs!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-db.sh
#
set -e
echo "========================================"
echo " Dockhand - Factory Reset Database (SQLite)"
echo "========================================"
echo ""
echo "WARNING: This will DELETE ALL DATA!"
echo ""
echo "This includes:"
echo " - All users and their settings"
echo " - All sessions"
echo " - Authentication settings"
echo " - Activity logs"
echo " - Environment configurations"
echo " - OIDC/SSO settings"
echo ""
echo "The database will be recreated on next startup."
echo ""
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Nothing to reset."
exit 0
fi
echo "Database: $DB_PATH"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
echo ""
echo "Creating backup before reset..."
BACKUP_FILE="${DB_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
cp "$DB_PATH" "$BACKUP_FILE"
echo "Backup saved to: $BACKUP_FILE"
echo ""
echo "Deleting database..."
rm -f "$DB_PATH"
rm -f "${DB_PATH}-wal"
rm -f "${DB_PATH}-shm"
echo ""
echo "Database deleted successfully."
echo ""
echo "Restart Dockhand to recreate a fresh database:"
echo " docker restart dockhand"
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
#
# SQLite: Emergency script to reset a user's password
# Use this if a user is locked out and needs a password reset
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-password.sh <username> <new_password>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-password.sh admin MyNewPassword123
#
set -e
echo "========================================"
echo " Dockhand - Reset User Password (SQLite)"
echo "========================================"
echo ""
# Check arguments
if [ -z "$1" ] || [ -z "$2" ]; then
echo "Usage: $0 <username> <new_password>"
echo ""
echo "Example:"
echo " $0 admin MyNewPassword123"
exit 1
fi
USERNAME="$1"
NEW_PASSWORD="$2"
# Validate password length
if [ ${#NEW_PASSWORD} -lt 8 ]; then
echo "Error: Password must be at least 8 characters"
exit 1
fi
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
echo "Set DOCKHAND_DB environment variable to specify the database path"
exit 1
fi
# Check if user exists
EXISTING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users WHERE username='$USERNAME';")
if [ "$EXISTING" -eq "0" ]; then
echo "Error: User '$USERNAME' not found"
echo ""
echo "Available users:"
sqlite3 "$DB_PATH" "SELECT username FROM users;" | while read user; do
echo " - $user"
done
exit 1
fi
echo "This script will reset the password for user '$USERNAME'."
echo ""
echo "Database: $DB_PATH"
echo "Username: $USERNAME"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Generate password hash using node (argon2 is available in the app)
echo ""
echo "Generating password hash..."
# Check if node and argon2 are available
if command -v node >/dev/null 2>&1; then
# Try to use argon2 from node_modules
PASSWORD_HASH=$(node -e "
try {
const argon2 = require('argon2');
argon2.hash('$NEW_PASSWORD').then(h => console.log(h)).catch(e => process.exit(1));
} catch(e) {
process.exit(1);
}
" 2>/dev/null)
if [ -z "$PASSWORD_HASH" ]; then
echo "Error: Could not generate password hash (argon2 not available)"
echo "This script requires Node.js with argon2 module"
exit 1
fi
else
echo "Error: Node.js is required to generate password hash"
exit 1
fi
echo "Resetting password for user '$USERNAME'..."
sqlite3 "$DB_PATH" "UPDATE users SET password_hash='$PASSWORD_HASH', updated_at=datetime('now') WHERE username='$USERNAME';"
if [ $? -eq 0 ]; then
echo ""
echo "Password reset successfully for user '$USERNAME'"
echo ""
# Invalidate sessions
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';")
sqlite3 "$DB_PATH" "DELETE FROM sessions WHERE user_id=$USER_ID;" 2>/dev/null || true
echo "All existing sessions have been invalidated."
echo "The user can now log in with the new password."
else
echo "Error: Failed to reset password"
exit 1
fi
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
#
# SQLite: Emergency script to restore the database from a backup
# WARNING: This will overwrite the current database!
#
# Usage:
# docker exec -it dockhand /app/scripts/emergency/sqlite/restore-db.sh <backup_file>
#
# Example:
# docker exec -it dockhand /app/scripts/emergency/sqlite/restore-db.sh /app/data/dockhand_backup_20240115_120000.db
#
# To copy backup into container first:
# docker cp ./dockhand_backup.db dockhand:/app/data/
#
set -e
echo "========================================"
echo " Dockhand - Restore Database (SQLite)"
echo "========================================"
echo ""
# Check argument
if [ -z "$1" ]; then
echo "Usage: $0 <backup_file>"
echo ""
echo "Example:"
echo " $0 /app/data/dockhand_backup_20240115_120000.db"
echo ""
echo "To copy backup into container first:"
echo " docker cp ./dockhand_backup.db dockhand:/app/data/"
exit 1
fi
BACKUP_FILE="$1"
# Default database path
DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}"
# Check if running locally (not in Docker)
if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then
DB_PATH="./data/db/dockhand.db"
fi
# Check if backup file exists
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
# Verify it's a valid SQLite database
if ! sqlite3 "$BACKUP_FILE" "SELECT 1;" >/dev/null 2>&1; then
echo "Error: File is not a valid SQLite database: $BACKUP_FILE"
exit 1
fi
# Get backup file size
BACKUP_SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}')
echo "WARNING: This will overwrite the current database!"
echo ""
echo "Current database: $DB_PATH"
echo "Backup to restore: $BACKUP_FILE ($BACKUP_SIZE)"
echo ""
printf "Continue? [y/N]: "
read CONFIRM
case "$CONFIRM" in
[yY]|[yY][eE][sS])
;;
*)
echo "Aborted."
exit 0
;;
esac
# Create backup of current database before restoring
if [ -f "$DB_PATH" ]; then
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
PRE_RESTORE_BACKUP="${DB_PATH}.pre-restore.$TIMESTAMP"
echo ""
echo "Creating backup of current database..."
cp "$DB_PATH" "$PRE_RESTORE_BACKUP"
echo "Current database backed up to: $PRE_RESTORE_BACKUP"
fi
echo ""
echo "Restoring database..."
# Remove WAL files if they exist
rm -f "${DB_PATH}-wal"
rm -f "${DB_PATH}-shm"
# Copy backup to database location
cp "$BACKUP_FILE" "$DB_PATH"
if [ $? -eq 0 ]; then
echo ""
echo "Database restored successfully!"
echo ""
echo "Restart Dockhand to apply changes:"
echo " docker restart dockhand"
else
echo "Error: Failed to restore database"
exit 1
fi
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bun
/**
* Generate changelog section in webpage/index.html from src/lib/data/changelog.json
* This ensures a single source of truth for release information
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
const ROOT_DIR = join(import.meta.dir, '..');
const CHANGELOG_PATH = join(ROOT_DIR, 'src/lib/data/changelog.json');
const INDEX_PATH = join(ROOT_DIR, 'webpage/index.html');
interface ChangelogEntry {
version: string;
date: string;
changes: Array<{ type: 'feature' | 'fix'; text: string }>;
imageTag: string;
}
// SVG icons for change types
const FEATURE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>`;
const FIX_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>`;
const TOGGLE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`;
const COPY_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function generateChangeItem(change: { type: 'feature' | 'fix'; text: string }): string {
const pillClass = change.type === 'feature' ? 'changelog-pill-feature' : 'changelog-pill-fix';
const svg = change.type === 'feature' ? FEATURE_SVG : FIX_SVG;
const label = change.type === 'feature' ? 'New' : 'Fix';
return ` <li><span class="changelog-pill ${pillClass}">${svg}${label}</span>${change.text}</li>`;
}
function generateLatestEntry(entry: ChangelogEntry): string {
const changes = entry.changes.map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} -->
<div class="changelog-entry">
<div class="changelog-header">
<div class="changelog-version">
<h3>${version}</h3>
<span class="changelog-badge">Latest</span>
</div>
<span class="changelog-date">${formatDate(entry.date)}</span>
</div>
<ul class="changelog-changes">
${changes}
</ul>
<div class="changelog-image-tag">
<span>Docker image:</span>
<code>${entry.imageTag}</code>
<button class="copy-btn" onclick="copyDockerImage(this, '${entry.imageTag}')" title="Copy to clipboard">${COPY_SVG}</button>
<span style="color: var(--text-muted); margin: 0 0.25rem;">or</span>
<code>fnsys/dockhand:latest</code>
<button class="copy-btn" onclick="copyDockerImage(this, 'fnsys/dockhand:latest')" title="Copy to clipboard">${COPY_SVG}</button>
</div>
</div>`;
}
function generateCollapsibleEntry(entry: ChangelogEntry): string {
const changes = entry.changes.map(generateChangeItem).join('\n');
const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`;
return ` <!-- ${version} (collapsible) -->
<div class="changelog-entry collapsible" data-version="${version}">
<div class="changelog-header">
<div class="changelog-version">
<h3>${version}</h3>
<span class="changelog-toggle">${TOGGLE_SVG}</span>
</div>
<span class="changelog-date">${formatDate(entry.date)}</span>
</div>
<div class="changelog-content">
<ul class="changelog-changes">
${changes}
</ul>
<div class="changelog-image-tag">
<span>Docker image:</span>
<code>${entry.imageTag}</code>
<button class="copy-btn" onclick="copyDockerImage(this, '${entry.imageTag}')" title="Copy to clipboard">${COPY_SVG}</button>
</div>
</div>
</div>`;
}
function generateChangelogSection(entries: ChangelogEntry[]): string {
if (entries.length === 0) {
return '';
}
const [latest, ...rest] = entries;
const latestHtml = generateLatestEntry(latest);
const restHtml = rest.map(generateCollapsibleEntry).join('\n');
return ` <!-- Changelog Section -->
<section class="changelog" id="changelog">
<div class="changelog-container">
<div class="section-header">
<div class="section-label">Changelog</div>
<h2 class="section-title">Release history</h2>
<p class="section-subtitle">Track our progress and see what's new in each version. <span style="color: #fbbf24; white-space: nowrap;">Spoiler: it gets better every time.</span></p>
</div>
<div class="changelog-list">
${latestHtml}
${restHtml}
</div>
</div>
</section>`;
}
// Read changelog.json
console.log('Reading changelog from:', CHANGELOG_PATH);
const changelog: ChangelogEntry[] = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8'));
console.log(`Found ${changelog.length} changelog entries`);
// Read index.html
console.log('Reading index.html from:', INDEX_PATH);
let indexHtml = readFileSync(INDEX_PATH, 'utf-8');
// Generate new changelog section
const newChangelogSection = generateChangelogSection(changelog);
// Replace changelog section using regex
// Match from "<!-- Changelog Section -->" to the closing "</section>" before "<!-- CTA -->"
const changelogRegex = / <!-- Changelog Section -->[\s\S]*?<\/section>(?=\s*\n\s*<!-- CTA -->)/;
if (!changelogRegex.test(indexHtml)) {
console.error('ERROR: Could not find changelog section in index.html');
console.error('Looking for pattern: <!-- Changelog Section --> ... </section> followed by <!-- CTA -->');
process.exit(1);
}
indexHtml = indexHtml.replace(changelogRegex, newChangelogSection);
// Also update softwareVersion in JSON-LD schema
if (changelog.length > 0) {
const latestVersion = changelog[0].version;
// Match "softwareVersion": "X.X" or "softwareVersion": "X.X.X"
const versionRegex = /"softwareVersion":\s*"[\d.]+"/;
if (versionRegex.test(indexHtml)) {
indexHtml = indexHtml.replace(versionRegex, `"softwareVersion": "${latestVersion}"`);
console.log(`Updated softwareVersion to: ${latestVersion}`);
}
}
// Write back to index.html
writeFileSync(INDEX_PATH, indexHtml);
console.log('');
console.log('Generated changelog in webpage/index.html');
console.log(` - Latest version: v${changelog[0]?.version || 'unknown'}`);
console.log(` - Total entries: ${changelog.length}`);
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bun
/**
* Generate static HTML pages for License and Privacy from .txt files
* This ensures a single source of truth for legal documents
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
const ROOT_DIR = join(import.meta.dir, '..');
const WEBPAGE_DIR = join(ROOT_DIR, 'webpage');
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function generateHtmlPage(title: string, content: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - Dockhand</title>
<link rel="icon" type="image/png" href="images/favicon.png">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo-img {
height: 40px;
}
.back-link {
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
h1 {
font-size: 1.75rem;
margin-bottom: 1.5rem;
color: #fff;
}
.content {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 2rem;
}
pre {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-wrap: break-word;
color: #c0c0c0;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255,255,255,0.1);
text-align: center;
font-size: 0.85rem;
color: #888;
}
footer a {
color: #60a5fa;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<header>
<a href="index.html">
<img src="images/logo-dark.webp" alt="Dockhand" class="logo-img">
</a>
<a href="index.html" class="back-link">&larr; Back to home</a>
</header>
<h1>${title}</h1>
<div class="content">
<pre>${escapeHtml(content)}</pre>
</div>
<footer>
<p>&copy; 2025-2026 Finsys / Jarek Krochmalski &middot; <a href="https://dockhand.pro">https://dockhand.pro</a></p>
</footer>
</div>
</body>
</html>`;
}
// Read the source files
const licenseContent = readFileSync(join(ROOT_DIR, 'LICENSE.txt'), 'utf-8');
const privacyContent = readFileSync(join(ROOT_DIR, 'PRIVACY.txt'), 'utf-8');
// Generate HTML pages
const licenseHtml = generateHtmlPage('License Terms and Conditions', licenseContent);
const privacyHtml = generateHtmlPage('Privacy Policy', privacyContent);
// Write to webpage directory
writeFileSync(join(WEBPAGE_DIR, 'license.html'), licenseHtml);
writeFileSync(join(WEBPAGE_DIR, 'privacy.html'), privacyHtml);
console.log('Generated legal pages:');
console.log(' - webpage/license.html');
console.log(' - webpage/privacy.html');
+575
View File
@@ -0,0 +1,575 @@
/**
* 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 } 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';
// 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 = _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 };
return { type: 'tcp', host: env.host, port: env.port || 2375, hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined };
}
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 {
url = 'http://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec';
if (target.hawserToken) headers['X-Hawser-Token'] = target.hawserToken;
}
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 {
url = 'http://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
if (target.hawserToken) fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken };
}
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('[WS] Open:', connId, containerId, 'target:', target.type);
// 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 {
const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target);
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() {},
open(socket) {
const body = JSON.stringify({ Detach: false, Tty: true });
const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : '';
socket.write('POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: localhost\\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 {
dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler });
}
dockerStreams.set(connId, { stream: dockerStream, execId, target });
} catch (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');
+128
View File
@@ -0,0 +1,128 @@
Business Source License 1.1
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Parameters
Licensor: Finsys / Jarek Krochmalski
Licensed Work: Dockhand
The Licensed Work is (c) 2025-2026 Finsys / Jarek Krochmalski.
Additional Use Grant: You may use the Licensed Work for any purpose, including
production use, provided that you do not offer the Licensed
Work, or any derivative work of the Licensed Work, to third
parties as a commercial hosted service, managed service, or
software-as-a-service (SaaS) offering where the primary value
proposition to users is Docker container management
functionality substantially similar to the Licensed Work.
For clarity, the following uses are explicitly permitted
without any restriction:
(a) Personal use, including home labs and hobby projects
(b) Internal business use within your organization, regardless
of the number of Docker environments managed
(c) Use by non-profit organizations and charitable entities
(d) Educational, academic, and research purposes
(e) Evaluation, testing, development, and demonstration purposes
(f) Embedding or integrating the Licensed Work into internal
tools or platforms that are not offered commercially to
third parties
(g) Use by managed service providers (MSPs) to manage Docker
infrastructure on behalf of their clients, provided the
MSP does not offer Dockhand itself as the service
Change Date: January 1, 2029
Change License: Apache License, Version 2.0
-----------------------------------------------------------------------------
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this License's text to license
your works, and to refer to it using the trademark "Business Source License",
as long as you comply with the Covenants of Licensor below.
-----------------------------------------------------------------------------
Covenants of Licensor
In consideration of the right to use this License's text and the "Business
Source License" name and trademark, Licensor covenants to MariaDB, and to all
other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later version,
or a license that is compatible with GPL Version 2.0 or a later version,
where "compatible" means that software provided under the Change License can
be included in a program with software provided under GPL Version 2.0 or a
later version. Licensor may specify additional Change Licenses without
limitation.
2. To either: (a) specify an additional grant of rights to use that does not
impose any additional restriction on the right granted in this License, as
the Additional Use Grant; or (b) insert the text "None".
3. To specify a Change Date.
4. Not to modify this License in any other way.
-----------------------------------------------------------------------------
Notice
The Business Source License (this document, or the "License") is not an Open
Source license. However, the Licensed Work will eventually be made available
under an Open Source License, as stated in this License.
-----------------------------------------------------------------------------
For licensing inquiries, commercial licensing, or enterprise features:
Website: https://dockhand.io
-----------------------------------------------------------------------------
-2
View File
@@ -13,5 +13,3 @@
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+10 -1
View File
@@ -4,6 +4,7 @@ import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
import { initCryptoFallback } from '$lib/server/crypto-fallback';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
@@ -20,6 +21,9 @@ let initialized = false;
if (!initialized) {
try {
// Initialize crypto fallback first (detects old kernels and logs status)
initCryptoFallback();
setServerStartTime(); // Track when server started
initDatabase();
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
@@ -68,11 +72,16 @@ const PUBLIC_PATHS = [
'/api/auth/oidc',
'/api/license',
'/api/changelog',
'/api/dependencies'
'/api/dependencies',
'/api/health'
];
// Check if path is public
function isPublicPath(pathname: string): boolean {
// Webhook endpoints have their own auth (signature/secret verification)
if (pathname.match(/^\/api\/git\/stacks\/\d+\/webhook$/)) return true;
if (pathname.match(/^\/api\/git\/webhook\/\d+$/)) return true;
return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/'));
}
+157 -25
View File
@@ -2,12 +2,54 @@
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
// and are only stored in the database (never written to .env file)
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
// Simple dotenv/env file language parser
const dotenvParser: StreamParser<{ inValue: boolean }> = {
startState() {
return { inValue: false };
},
token(stream, state) {
// Start of line
if (stream.sol()) {
state.inValue = false;
// Skip leading whitespace
stream.eatSpace();
// Comment line
if (stream.peek() === '#') {
stream.skipToEnd();
return 'comment';
}
}
// If in value part, consume the rest
if (state.inValue) {
stream.skipToEnd();
return 'string';
}
// Variable name before =
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) {
if (stream.peek() === '=') {
return 'variableName.definition';
}
return 'variableName';
}
// Equals sign - switch to value mode
if (stream.eat('=')) {
state.inValue = true;
return 'operator';
}
// Skip anything else
stream.next();
return null;
}
};
// Docker Compose keywords for autocomplete
const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version'];
@@ -172,7 +214,10 @@
variableMarkers?: VariableMarker[];
}
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props();
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props();
// Keep markers reactive - destructured props with defaults lose reactivity
const variableMarkers = $derived(variableMarkersProp);
let container: HTMLDivElement;
let view: EditorView | null = null;
@@ -180,6 +225,9 @@
// Mutable ref for callback - allows updating without recreating editor
let onchangeRef: ((value: string) => void) | undefined = onchange;
// Flag to suppress onchange during programmatic value sync
let isSyncingExternalValue = false;
// Keep callback ref updated when prop changes
$effect(() => {
onchangeRef = onchange;
@@ -372,38 +420,61 @@
// Effect to update variable markers
const updateMarkersEffect = StateEffect.define<VariableMarker[]>();
// State field to store current markers (used for recalculation on doc change)
const currentMarkersField = StateField.define<VariableMarker[]>({
create() {
return [];
},
update(markers, tr) {
for (const effect of tr.effects) {
if (effect.is(updateMarkersEffect)) {
return effect.value;
}
}
return markers;
}
});
// State field to track variable markers (gutter)
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
// Recalculates on doc change to avoid position mapping issues
const variableMarkersField = StateField.define<RangeSet<GutterMarker>>({
create() {
// Start empty - markers will be pushed via effect
return RangeSet.empty;
},
update(markers, tr) {
// Check for marker updates first
for (const effect of tr.effects) {
if (effect.is(updateMarkersEffect)) {
return createVariableDecorations(tr.state.doc, effect.value);
}
}
// Don't recalculate on docChanged - wait for explicit effect from parent
// Recalculate on doc change using stored markers
if (tr.docChanged) {
const currentMarkers = tr.state.field(currentMarkersField);
return createVariableDecorations(tr.state.doc, currentMarkers);
}
return markers;
}
});
// State field to track value decorations (inline widgets)
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
// Recalculates on doc change to avoid widget duplication issues
const valueDecorationsField = StateField.define<DecorationSet>({
create() {
// Start empty - decorations will be pushed via effect
return Decoration.none;
},
update(decorations, tr) {
// Check for marker updates first
for (const effect of tr.effects) {
if (effect.is(updateMarkersEffect)) {
return createValueDecorations(tr.state.doc, effect.value);
}
}
// Don't recalculate on docChanged - wait for explicit effect from parent
// Recalculate on doc change using stored markers
if (tr.docChanged) {
const currentMarkers = tr.state.field(currentMarkersField);
return createValueDecorations(tr.state.doc, currentMarkers);
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
@@ -453,6 +524,9 @@
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
default:
return [];
}
@@ -542,6 +616,13 @@
// Track if we're initialized (prevents multiple createEditor calls)
let initialized = false;
// Debounce timer for marker updates (prevents flicker during fast typing)
let markerUpdateTimer: ReturnType<typeof setTimeout> | null = null;
const MARKER_UPDATE_DEBOUNCE_MS = 300;
// Track last applied markers to avoid redundant updates
let lastAppliedMarkersJson = '';
function createEditor() {
if (!container || view || initialized) return;
initialized = true;
@@ -551,12 +632,14 @@
: [dockhandLight, syntaxHighlighting(defaultHighlightStyle)];
// Build autocompletion config - add Docker Compose completions for YAML
// Note: activateOnTyping can interfere with key repeat, so we disable it
// Users can still trigger autocomplete manually with Ctrl+Space
const autocompletionConfig = language === 'yaml'
? autocompletion({
override: [composeCompletions, composeValueCompletions],
activateOnTyping: true
activateOnTyping: false
})
: autocompletion();
: autocompletion({ activateOnTyping: false });
const extensions = [
lineNumbers(),
@@ -587,25 +670,30 @@
}
// Always add variable markers gutter and value decorations (can be updated dynamically)
extensions.push(variableMarkersField, variableGutter, valueDecorationsField);
extensions.push(currentMarkersField, variableMarkersField, variableGutter, valueDecorationsField);
const state = EditorState.create({
doc: value,
extensions
});
// Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener
// Custom transaction handler - applies transactions synchronously but defers callback
// Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104
const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => {
if (!view) return;
// Apply all transactions
// Apply all transactions synchronously (required by CodeMirror)
view.update(trs);
// Check if any transaction changed the document
// Skip onchange during programmatic value sync (only fire for user edits)
const lastChangingTr = trs.findLast(tr => tr.docChanged);
if (lastChangingTr && onchangeRef) {
onchangeRef(lastChangingTr.newDoc.toString());
if (lastChangingTr && onchangeRef && !isSyncingExternalValue) {
// Call synchronously to ensure parent state updates before any
// reactive $effect runs - this prevents race conditions on iPad Safari
// where paste content was being overwritten by stale external value
const newContent = lastChangingTr.newDoc.toString();
onchangeRef(newContent);
}
};
@@ -615,7 +703,6 @@
dispatchTransactions
});
// Push initial markers if provided
if (variableMarkers.length > 0) {
view.dispatch({
@@ -625,11 +712,16 @@
}
function destroyEditor() {
if (markerUpdateTimer) {
clearTimeout(markerUpdateTimer);
markerUpdateTimer = null;
}
if (view) {
view.destroy();
view = null;
}
initialized = false;
lastAppliedMarkersJson = '';
}
// Get current editor content
@@ -656,11 +748,35 @@
}
// Update variable markers - this is the key method for parent to call
export function updateVariableMarkers(markers: VariableMarker[]) {
if (view) {
view.dispatch({
effects: updateMarkersEffect.of(markers)
});
// Debounced to prevent flicker during fast typing
export function updateVariableMarkers(markers: VariableMarker[], immediate = false) {
if (!view) return;
// Check if markers actually changed (compare by content, not reference)
const newJson = JSON.stringify(markers);
if (newJson === lastAppliedMarkersJson) {
return; // No change, skip update
}
// Clear any pending update
if (markerUpdateTimer) {
clearTimeout(markerUpdateTimer);
markerUpdateTimer = null;
}
const applyUpdate = () => {
if (view) {
lastAppliedMarkersJson = newJson;
view.dispatch({
effects: updateMarkersEffect.of(markers)
});
}
};
if (immediate) {
applyUpdate();
} else {
markerUpdateTimer = setTimeout(applyUpdate, MARKER_UPDATE_DEBOUNCE_MS);
}
}
@@ -693,12 +809,29 @@
});
// Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers)
// Uses the debounced update to prevent flicker during fast typing
$effect(() => {
const markers = variableMarkers;
if (view && markers) {
view.dispatch({
effects: updateMarkersEffect.of(markers)
});
updateVariableMarkers(markers);
}
});
// Sync external value changes to the editor (e.g., when parent clears the content)
$effect(() => {
const externalValue = value;
if (view) {
const currentContent = view.state.doc.toString();
// Only update if the external value differs from editor content
// This prevents feedback loops from editor changes
if (externalValue !== currentContent) {
// Suppress onchange during programmatic sync - only user edits should trigger it
isSyncingExternalValue = true;
view.dispatch({
changes: { from: 0, to: currentContent.length, insert: externalValue }
});
isSyncingExternalValue = false;
}
}
});
</script>
@@ -706,7 +839,6 @@
<div
bind:this={container}
class="h-full w-full overflow-hidden {className}"
onkeydown={(e) => e.stopPropagation()}
></div>
<style>
-1
View File
@@ -61,7 +61,6 @@
});
function handleConfirm() {
console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm);
onConfirm();
open = false;
onOpenChange(false);
+6 -1
View File
@@ -46,6 +46,8 @@
let status = $state<PullStatus>('idle');
let image = $state(initialImageName);
let duration = $state(0);
// Track whether image was set from initial prop vs typed by user
let hasAutoStarted = $state(false);
// Notify parent of status changes
$effect(() => {
@@ -82,8 +84,10 @@
onImageChange?.(image);
});
// Auto-start only once for prefilled images, not when user is typing
$effect(() => {
if (autoStart && image && status === 'idle') {
if (autoStart && initialImageName && image === initialImageName && status === 'idle' && !hasAutoStarted) {
hasAutoStarted = true;
startPull();
}
});
@@ -133,6 +137,7 @@
layerOrder = 0;
outputLines = [];
duration = 0;
hasAutoStarted = false;
}
export function getImage() {
+9 -2
View File
@@ -27,6 +27,7 @@
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
onchange?: () => void;
}
let {
@@ -36,7 +37,8 @@
showSource = false,
sources = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
existingSecretKeys = new Set<string>()
existingSecretKeys = new Set<string>(),
onchange
}: Props = $props();
// Check if a variable is an existing secret that was loaded from DB
@@ -46,14 +48,17 @@
function addVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }];
onchange?.();
}
function removeVariable(index: number) {
variables = variables.filter((_, i) => i !== index);
onchange?.();
}
function toggleSecret(index: number) {
variables[index].isSecret = !variables[index].isSecret;
onchange?.();
}
// Check if a variable key is missing (required but not defined)
@@ -99,7 +104,7 @@
<div class="space-y-3">
<!-- Variables List -->
<div class="space-y-3">
{#each variables as variable, index}
{#each variables as variable, index (index)}
{@const source = getSource(variable.key)}
{@const isVarRequired = isRequired(variable.key)}
{@const isVarOptional = isOptional(variable.key)}
@@ -163,6 +168,7 @@
<Input
bind:value={variable.key}
disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs"
/>
</div>
@@ -174,6 +180,7 @@
bind:value={variable.value}
type={variable.isSecret ? 'password' : 'text'}
disabled={readonly}
oninput={() => onchange?.()}
class="h-9 font-mono text-xs"
/>
</div>
+354 -127
View File
@@ -1,12 +1,15 @@
<script lang="ts">
import { tick } from 'svelte';
import { Button } from '$lib/components/ui/button';
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { Plus, Info, Upload, Trash2 } from 'lucide-svelte';
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
variables: EnvVar[];
variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets)
rawContent: string; // Bindable - raw .env file content (comments preserved, no secrets)
validation?: ValidationResult | null;
readonly?: boolean;
showSource?: boolean;
@@ -14,12 +17,14 @@
placeholder?: { key: string; value: string };
infoText?: string;
existingSecretKeys?: Set<string>;
theme?: 'light' | 'dark';
class?: string;
onchange?: () => void;
}
let {
variables = $bindable(),
variables = $bindable([]),
rawContent = $bindable(''),
validation = null,
readonly = false,
showSource = false,
@@ -27,48 +32,225 @@
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
infoText,
existingSecretKeys = new Set<string>(),
theme = 'dark',
class: className = '',
onchange
}: Props = $props();
const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode';
let fileInputRef: HTMLInputElement;
let viewMode = $state<'form' | 'text'>(
(typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY_VIEW_MODE) as 'form' | 'text') || 'form'
);
let confirmClearOpen = $state(false);
let contentAreaRef: HTMLDivElement;
let parseWarnings = $state<string[]>([]);
function addEnvVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }];
// Count of secrets (for display in hint)
const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length);
/**
* Sync variables with rawContent after initial load.
* Pass the loaded data directly to avoid timing issues with bindable props.
* Merges: secrets from loadedVars (DB) + non-secrets from loadedRaw (file).
*/
export function syncAfterLoad(loadedVars: EnvVar[], loadedRaw: string) {
if (!loadedRaw.trim()) {
// No raw content - just use the loaded variables as-is
variables = loadedVars;
rawContent = '';
return;
}
const { vars: rawVars } = parseRawContent(loadedRaw);
// Secrets come from loadedVars (DB), non-secrets come from loadedRaw (file)
const secrets = loadedVars.filter(v => v.isSecret);
// Also keep non-secrets from loadedVars that aren't in raw (new vars added before first save)
const rawKeys = new Set(rawVars.map(v => v.key));
const newNonSecrets = loadedVars.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
// Set both at once to avoid any intermediate states
variables = [...rawVars, ...newNonSecrets, ...secrets];
rawContent = loadedRaw;
}
function handleLoadFromFile() {
fileInputRef?.click();
}
/**
* Parse raw content to extract non-secret variables.
*/
function parseRawContent(content: string): { vars: EnvVar[], warnings: string[] } {
const result: EnvVar[] = [];
const warnings: string[] = [];
let lineNum = 0;
function parseEnvFile(content: string): EnvVar[] {
const lines = content.split('\n');
const envVars: EnvVar[] = [];
for (const line of lines) {
// Skip empty lines and comments
for (const line of content.split('\n')) {
lineNum++;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Parse KEY=VALUE format
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
if (eqIndex === -1) {
warnings.push(`Line ${lineNum}: "${trimmed.slice(0, 30)}${trimmed.length > 30 ? '...' : ''}" (no = found)`);
continue;
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
let value = trimmed.slice(eqIndex + 1);
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
envVars.push({ key, value, isSecret: false });
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
warnings.push(`Line ${lineNum}: "${key}" (invalid variable name)`);
continue;
}
result.push({ key, value, isSecret: false });
}
}
return envVars;
return { vars: result, warnings };
}
/**
* Sync variables (non-secrets) TO rawContent.
* Preserves comments and formatting. Secrets are excluded.
*/
function syncVariablesToRaw() {
const nonSecretVars = variables.filter(v => v.key.trim() && !v.isSecret);
// If no raw content exists, generate fresh
if (!rawContent.trim()) {
if (nonSecretVars.length > 0) {
rawContent = nonSecretVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n';
}
return;
}
// Update existing raw content - preserve comments, update/add/remove variables
const varMap = new Map(nonSecretVars.map(v => [v.key.trim(), v]));
const usedKeys = new Set<string>();
const lines = rawContent.split('\n');
const resultLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Keep comments and blank lines
if (!trimmed || trimmed.startsWith('#')) {
resultLines.push(line);
continue;
}
// Check if this is a variable line
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex).trim();
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
const varData = varMap.get(key);
if (varData) {
// Update value
resultLines.push(`${key}=${varData.value}`);
usedKeys.add(key);
}
// If not in varMap, variable was deleted - skip line
continue;
}
}
resultLines.push(line);
}
// Append new variables
for (const v of nonSecretVars) {
if (!usedKeys.has(v.key.trim())) {
resultLines.push(`${v.key.trim()}=${v.value}`);
}
}
let result = resultLines.join('\n');
if (result && !result.endsWith('\n')) {
result += '\n';
}
rawContent = result;
}
/**
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
const existingSecrets = variables.filter(v => v.isSecret);
// Merge: non-secrets from raw + existing secrets
variables = [...vars, ...existingSecrets];
}
/**
* Call before saving. Ensures variables and rawContent are in sync.
* Always syncs variables→raw to get proper .env content for disk.
*/
export function prepareForSave(): { rawContent: string; variables: EnvVar[] } {
// If in text view, first sync raw→variables to capture edits
if (viewMode === 'text') {
syncRawToVariables();
}
// Then sync variables→raw to ensure rawContent is up to date
syncVariablesToRaw();
return {
rawContent,
variables: variables.filter(v => v.key.trim())
};
}
function handleTextChange(value: string) {
rawContent = value;
syncRawToVariables(); // Sync to variables so parent's envVars updates (for compose decorations)
onchange?.();
}
function handleViewModeChange(newMode: 'form' | 'text') {
if (newMode === 'text' && viewMode === 'form') {
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
}
viewMode = newMode;
localStorage.setItem(STORAGE_KEY_VIEW_MODE, newMode);
}
async function addEnvVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }];
onchange?.();
await tick();
if (contentAreaRef) {
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
}
}
async function addMissingVariable(key: string) {
variables = [...variables, { key, value: '', isSecret: false }];
onchange?.();
await tick();
if (contentAreaRef) {
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
}
}
function handleLoadFromFile() {
fileInputRef?.click();
}
function handleFileSelect(event: Event) {
@@ -78,90 +260,121 @@
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const parsedVars = parseEnvFile(content);
if (parsedVars.length > 0) {
// Get existing keys to avoid duplicates
const existingKeys = new Set(variables.filter(v => v.key.trim()).map(v => v.key.trim()));
// Filter empty entries from current variables
const nonEmptyVars = variables.filter(v => v.key.trim());
// Add new variables, updating existing ones or appending new
for (const newVar of parsedVars) {
if (existingKeys.has(newVar.key)) {
// Update existing variable
const idx = nonEmptyVars.findIndex(v => v.key.trim() === newVar.key);
if (idx !== -1) {
nonEmptyVars[idx] = { ...nonEmptyVars[idx], value: newVar.value };
}
} else {
// Add new variable
nonEmptyVars.push(newVar);
existingKeys.add(newVar.key);
}
}
variables = nonEmptyVars;
// Notify parent of change (important for async file load)
onchange?.();
}
rawContent = e.target?.result as string;
// Parse and merge with existing secrets
syncRawToVariables();
// Switch to text view to show loaded content
viewMode = 'text';
localStorage.setItem(STORAGE_KEY_VIEW_MODE, 'text');
onchange?.();
};
reader.readAsText(file);
// Reset input so the same file can be selected again
input.value = '';
}
function clearAllVariables() {
function clearAll() {
rawContent = '';
variables = [];
onchange?.();
}
// Count of non-empty variables
const hasVariables = $derived(variables.some(v => v.key.trim()));
const hasContent = $derived(!!rawContent?.trim() || variables.some(v => v.key.trim()));
</script>
<div class="flex flex-col h-full {className}">
<!-- Header -->
<div class="px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-700 flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-500 dark:text-zinc-400">Environment variables</span>
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<p class="text-xs">{infoText}</p>
<!-- Header row: title + info + view toggle + validation pills + actions -->
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span class="text-xs text-zinc-500 dark:text-zinc-400 shrink-0">Environment variables</span>
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400 shrink-0" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
<p class="text-xs text-left">{infoText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Tooltip.Portal>
</Tooltip.Root>
{/if}
<!-- View mode toggle -->
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 shrink-0">
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('form')}
title="Form view"
>
<List class="w-3 h-3" />
</button>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('text')}
title="Text view (raw .env file)"
>
<FileText class="w-3 h-3" />
</button>
</div>
<!-- Validation status pills -->
{#if validation}
<div class="flex gap-1 flex-wrap">
{#if validation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{validation.missing.length} missing
</span>
{/if}
{#if validation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{validation.required.length - validation.missing.length} defined
</span>
{/if}
{#if validation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{validation.optional.length} optional
</span>
{/if}
</div>
{/if}
</div>
<!-- Actions - right-aligned -->
{#if !readonly}
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 shrink-0">
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
<Upload class="w-3.5 h-3.5 mr-1" />
Load .env
Load
</Button>
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
<Plus class="w-3.5 h-3.5 mr-1" />
Add
</Button>
{#if hasVariables}
<ConfirmPopover
title="Clear all variables"
description="This will remove all environment variables. This cannot be undone."
confirmText="Clear all"
onConfirm={clearAllVariables}
>
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
{#if viewMode === 'form'}
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
<Plus class="w-3.5 h-3.5 mr-1" />
Add
</Button>
{/if}
<ConfirmPopover
bind:open={confirmClearOpen}
title="Clear all variables?"
action="clear"
itemType="environment variables"
confirmText="Clear all"
onConfirm={clearAll}
onOpenChange={(o) => confirmClearOpen = o}
>
{#snippet children({ open })}
<Button
type="button"
size="sm"
variant="ghost"
class="h-6 text-xs px-2 {hasContent ? 'text-destructive hover:text-destructive' : 'text-muted-foreground/50 cursor-not-allowed'}"
disabled={!hasContent}
>
<Trash2 class="w-3.5 h-3.5 mr-1" />
Clear
</Button>
</ConfirmPopover>
{/if}
{/snippet}
</ConfirmPopover>
</div>
<input
bind:this={fileInputRef}
@@ -172,47 +385,49 @@
/>
{/if}
</div>
<!-- Variable syntax help -->
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
</div>
<!-- Validation status pills -->
{#if validation}
<div class="flex flex-wrap gap-1">
{#if validation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{validation.missing.length} missing
</span>
{/if}
{#if validation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{validation.required.length - validation.missing.length} required
</span>
{/if}
{#if validation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{validation.optional.length} optional
</span>
{/if}
{#if validation.unused.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{validation.unused.length} unused
</span>
{/if}
<!-- Help text -->
{#if viewMode === 'form'}
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
</div>
{:else if secretCount > 0}
<!-- Text view hint about secrets (only shown when secrets exist) -->
<div class="flex items-start gap-2 px-2.5 py-2 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<ShieldAlert class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div class="text-xs text-amber-700 dark:text-amber-300">
<span class="font-medium">{secretCount} secret{secretCount === 1 ? '' : 's'} not shown.</span>
<span class="text-amber-600 dark:text-amber-400">Secrets are never written to disk and are injected via shell environment when the stack starts.</span>
</div>
</div>
{/if}
<!-- Add missing variables -->
{#if validation && validation.missing.length > 0 && !readonly}
<!-- Parse warnings (form mode only) -->
{#if viewMode === 'form' && parseWarnings.length > 0}
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<div class="text-2xs text-amber-700 dark:text-amber-300">
<span class="font-medium">Some lines couldn't be parsed:</span>
<ul class="mt-0.5 list-disc list-inside">
{#each parseWarnings.slice(0, 3) as warning}
<li>{warning}</li>
{/each}
{#if parseWarnings.length > 3}
<li>...and {parseWarnings.length - 3} more</li>
{/if}
</ul>
<p class="mt-1 text-amber-600 dark:text-amber-400">Switch to text view to edit these lines.</p>
</div>
</div>
{/if}
<!-- Add missing variables (form mode only) -->
{#if viewMode === 'form' && validation && validation.missing.length > 0 && !readonly}
<div class="flex flex-wrap gap-1 items-center">
<span class="text-xs text-muted-foreground mr-1">Add missing:</span>
{#each validation.missing as missing}
<button
type="button"
onclick={() => {
variables = [...variables, { key: missing, value: '', isSecret: false }];
}}
onclick={() => addMissingVariable(missing)}
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
>
{missing}
@@ -221,16 +436,28 @@
</div>
{/if}
</div>
<!-- Variables list -->
<div class="flex-1 overflow-auto px-4 py-3">
<StackEnvVarsEditor
bind:variables
{validation}
{readonly}
{showSource}
{sources}
{placeholder}
{existingSecretKeys}
/>
<!-- Content area -->
<div bind:this={contentAreaRef} class="flex-1 overflow-auto px-4 py-3">
{#if viewMode === 'form'}
<StackEnvVarsEditor
bind:variables
{validation}
{readonly}
{showSource}
{sources}
{placeholder}
{existingSecretKeys}
{onchange}
/>
{:else}
<CodeEditor
value={rawContent}
language="dotenv"
theme={theme}
readonly={readonly}
onchange={handleTextChange}
class="h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
{/if}
</div>
</div>
+1 -1
View File
@@ -111,7 +111,7 @@
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[350px] p-0" align="start">
<Popover.Content class="w-[350px] p-0 z-[200]" align="start">
<Command.Root shouldFilter={false}>
<Command.Input bind:value={searchQuery} placeholder="Search timezone..." />
<Command.List class="max-h-[300px]">
+112 -21
View File
@@ -67,6 +67,7 @@
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
emptyState?: Snippet;
loadingState?: Snippet;
footer?: Snippet;
}
let {
@@ -100,7 +101,8 @@
headerCell,
cell,
emptyState,
loadingState
loadingState,
footer
}: Props = $props();
// Column configuration
@@ -112,14 +114,16 @@
// Grid preferences (reactive)
const gridPrefs = $derived($gridPreferencesStore);
// Get ordered visible columns from preferences
// Get ordered visible columns from preferences (excluding fixed columns)
const orderedColumns = $derived.by(() => {
const prefs = gridPrefs[gridId];
if (!prefs?.columns?.length) {
// Default: all configurable columns visible
return columnConfigs.filter((c) => !c.fixed).map((c) => c.id);
}
return prefs.columns.filter((c) => c.visible).map((c) => c.id);
// Filter out fixed columns - they're rendered separately via fixedStartCols/fixedEndCols
const fixedIds = new Set([...fixedStartCols, ...fixedEndCols]);
return prefs.columns.filter((c) => c.visible && !fixedIds.has(c.id)).map((c) => c.id);
});
// Identify visible grow columns (columns with grow: true that are currently visible)
@@ -152,6 +156,9 @@
// RAF throttling for performance
let resizeRAF: number | null = null;
let scrollRAF: number | null = null;
let visibleRangeRAF: number | null = null;
let containerResizeRAF: number | null = null;
let loadMorePending = false;
// Helper to get base width for a column (without grow calculation)
function getBaseWidth(colId: string): number {
@@ -346,20 +353,58 @@
// Virtual scroll calculations
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
// Memoization state for visibleData to prevent creating new arrays on every scroll
let prevStartIndex = -1;
let prevEndIndex = -1;
let prevDataRef: T[] | null = null;
let cachedVisibleData: T[] = [];
// Memoized startIndex/endIndex/visibleData calculation
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
const endIndex = $derived(
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
);
const visibleData = $derived(virtualScroll ? data.slice(startIndex, endIndex) : data);
// Memoized visibleData - only create new array when bounds or data actually change
const visibleData = $derived.by(() => {
if (!virtualScroll) return data;
// If data reference changed, we must reslice
const dataChanged = data !== prevDataRef;
// Only create new array if bounds or data actually changed
if (!dataChanged && startIndex === prevStartIndex && endIndex === prevEndIndex && cachedVisibleData.length > 0) {
return cachedVisibleData;
}
prevStartIndex = startIndex;
prevEndIndex = endIndex;
prevDataRef = data;
cachedVisibleData = data.slice(startIndex, endIndex);
return cachedVisibleData;
});
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
// Notify parent of visible range changes
// Notify parent of visible range changes (throttled via RAF)
$effect(() => {
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
// Calculate actual visible range (without buffer)
const visibleStart = Math.max(1, Math.floor(scrollTop / rowHeight) + 1);
const visibleEnd = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight));
onVisibleRangeChange(visibleStart, Math.max(visibleEnd, visibleStart), data.length);
// Capture values for RAF callback
const st = scrollTop;
const ch = containerHeight;
const len = data.length;
const rh = rowHeight;
const cb = onVisibleRangeChange;
if (visibleRangeRAF) cancelAnimationFrame(visibleRangeRAF);
visibleRangeRAF = requestAnimationFrame(() => {
visibleRangeRAF = null;
// Calculate actual visible range (without buffer)
const visibleStart = Math.max(1, Math.floor(st / rh) + 1);
const visibleEnd = Math.min(len, Math.ceil((st + ch) / rh));
cb(visibleStart, Math.max(visibleEnd, visibleStart), len);
});
}
});
@@ -376,11 +421,14 @@
// Update container height on scroll (in case of resize)
containerHeight = target.clientHeight;
// Infinite scroll trigger
if (hasMore && onLoadMore) {
// Infinite scroll trigger (with guard to prevent repeated calls)
if (hasMore && onLoadMore && !loadMorePending) {
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < loadMoreThreshold) {
loadMorePending = true;
onLoadMore();
// Reset after a short delay to allow the next load
setTimeout(() => { loadMorePending = false; }, 100);
}
}
});
@@ -398,12 +446,17 @@
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
scrollContainerWidth = entry.contentRect.width;
if (virtualScroll) {
containerHeight = entry.contentRect.height;
// Throttle with RAF to prevent "ResizeObserver loop" warnings
if (containerResizeRAF) return;
containerResizeRAF = requestAnimationFrame(() => {
containerResizeRAF = null;
for (const entry of entries) {
scrollContainerWidth = entry.contentRect.width;
if (virtualScroll) {
containerHeight = entry.contentRect.height;
}
}
}
});
});
resizeObserver.observe(scrollContainer);
@@ -417,6 +470,8 @@
onDestroy(() => {
if (resizeRAF) cancelAnimationFrame(resizeRAF);
if (scrollRAF) cancelAnimationFrame(scrollRAF);
if (visibleRangeRAF) cancelAnimationFrame(visibleRangeRAF);
if (containerResizeRAF) cancelAnimationFrame(containerResizeRAF);
});
// Set context for child components
@@ -440,15 +495,47 @@
highlightedKey
});
// Helper to get row state
// Row state cache to prevent creating new objects on every scroll
// Use $derived to track dependencies synchronously (unlike $effect which is async)
let rowStateCache = new WeakMap<object, DataGridRowState>();
// Track cache invalidation keys - when these change, cache is stale
let cachedSelectedKeysRef: Set<unknown> | null = null;
let cachedExpandedKeysRef: Set<unknown> | null = null;
let cachedHighlightedKeyRef: unknown = undefined;
// Helper to get row state (memoized via WeakMap)
// Cache is invalidated synchronously when selection/expansion changes
function getRowState(item: T, index: number): DataGridRowState {
return {
const actualIndex = virtualScroll ? startIndex + index : index;
// Check if cache needs to be cleared (synchronous check)
if (selectedKeys !== cachedSelectedKeysRef ||
expandedKeys !== cachedExpandedKeysRef ||
highlightedKey !== cachedHighlightedKeyRef) {
rowStateCache = new WeakMap();
cachedSelectedKeysRef = selectedKeys;
cachedExpandedKeysRef = expandedKeys;
cachedHighlightedKeyRef = highlightedKey;
}
// Try to get cached state
const cached = rowStateCache.get(item as object);
if (cached && cached.index === actualIndex) {
return cached;
}
// Create new state object and cache it
const state: DataGridRowState = {
isSelected: isSelected(item[keyField]),
isHighlighted: highlightedKey === item[keyField],
isSelectable: isItemSelectable(item),
isExpanded: isExpanded(item[keyField]),
index: virtualScroll ? startIndex + index : index
index: actualIndex
};
rowStateCache.set(item as object, state);
return state;
}
// Helper to check if column is resizable
@@ -672,7 +759,7 @@
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
@@ -781,7 +868,7 @@
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
@@ -841,6 +928,10 @@
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Footer (rendered at the bottom of virtual scroll) -->
{#if footer}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} class="p-0 border-none">{@render footer()}</td></tr>
{/if}
</tbody>
</table>
{:else}
+1 -1
View File
@@ -37,7 +37,7 @@
<CurrentIcon class="h-4 w-4" />
</Button>
</Popover.Trigger>
<Popover.Content class="w-80 p-3" align="start">
<Popover.Content class="w-80 p-3 z-[200]" align="start">
<div class="space-y-3">
<Input
bind:value={searchQuery}
@@ -62,7 +62,7 @@
<span class="text-xs">{placeholder}</span>
{/if}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Popover.Content class="w-auto p-0 z-[200]" align="start">
<Calendar
type="single"
value={dateValue}
@@ -27,7 +27,7 @@
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
"bg-background fixed start-[50%] top-[50%] z-[150] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
!className?.includes('max-w-') && "sm:max-w-lg",
className
)}
@@ -13,7 +13,7 @@
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[150] bg-black/50",
className
)}
{...restProps}
@@ -21,7 +21,7 @@
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-[200] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
@@ -13,7 +13,7 @@
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-[200] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
@@ -0,0 +1,183 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte';
interface Props {
open: boolean;
title: string;
message: string;
details?: string;
onClose: () => void;
}
let { open = $bindable(), title, message, details, onClose }: Props = $props();
let copied = $state(false);
interface ParsedOutput {
warnings: string[];
steps: { action: string; status: 'creating' | 'created' | 'starting' | 'started' | 'error' }[];
error: string | null;
raw: string;
parsed: boolean;
}
// Parse docker compose output into structured format
function parseDockerOutput(text: string): ParsedOutput {
const result: ParsedOutput = {
warnings: [],
steps: [],
error: null,
raw: text,
parsed: false
};
try {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
for (const line of lines) {
// Parse time="..." level=warning msg="..."
const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="([^"]+)"/);
if (warningMatch) {
result.warnings.push(warningMatch[1]);
result.parsed = true;
continue;
}
// Parse container/network steps: "Network foo Creating" or "Container foo-1 Created"
const stepMatch = line.match(/^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i);
if (stepMatch) {
const [, type, name, status] = stepMatch;
const normalizedStatus = status.toLowerCase() as any;
result.steps.push({
action: `${type} ${name}`,
status: normalizedStatus
});
result.parsed = true;
continue;
}
// Parse error lines
if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) {
result.error = result.error ? `${result.error}\n${line}` : line;
result.parsed = true;
continue;
}
}
// If we parsed something but have no clear error, check for remaining unparsed content
if (result.parsed && !result.error) {
const unparsed = lines.filter(line => {
if (line.match(/time="[^"]*"\s+level=warning/)) return false;
if (line.match(/^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i)) return false;
return true;
});
if (unparsed.length > 0) {
result.error = unparsed.join('\n');
}
}
} catch {
// Parsing failed, will show raw message
}
return result;
}
const parsed = $derived(parseDockerOutput(message));
async function copyError() {
const text = details ? `${message}\n\n${details}` : message;
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function handleClose() {
open = false;
onClose();
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => !o && handleClose()}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-destructive">
<AlertCircle class="w-5 h-5" />
{title}
</Dialog.Title>
</Dialog.Header>
<div class="space-y-3 max-h-[60vh] overflow-y-auto">
{#if parsed.parsed}
<!-- Parsed docker compose output -->
{#if parsed.warnings.length > 0}
<div class="space-y-1">
{#each parsed.warnings as warning}
<div class="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 px-2.5 py-1.5 rounded-md">
<AlertTriangle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
{/each}
</div>
{/if}
{#if parsed.steps.length > 0}
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-2.5 space-y-1">
{#each parsed.steps as step}
<div class="flex items-center gap-2 text-xs font-mono">
{#if step.status === 'created' || step.status === 'started' || step.status === 'removed' || step.status === 'stopped'}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else if step.status === 'error'}
<XCircle class="w-3.5 h-3.5 text-red-500" />
{:else}
<div class="w-3.5 h-3.5 rounded-full border-2 border-zinc-400"></div>
{/if}
<span class="text-zinc-600 dark:text-zinc-300">{step.action}</span>
<span class="text-zinc-400 dark:text-zinc-500 capitalize">{step.status}</span>
</div>
{/each}
</div>
{/if}
{#if parsed.error}
<div class="bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md p-3 relative group">
<button
onclick={copyError}
class="absolute top-2 right-2 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
title="Copy error"
>
{#if copied}
<Check class="w-3.5 h-3.5" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
<pre class="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap break-words font-mono pr-6">{parsed.error}</pre>
</div>
{/if}
{:else}
<!-- Fallback to raw message -->
<div class="relative group">
<button
onclick={copyError}
class="absolute top-1 right-1 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy error"
>
{#if copied}
<Check class="w-3.5 h-3.5" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
<pre class="text-sm whitespace-pre-wrap font-sans pr-6">{message}</pre>
</div>
{/if}
{#if details}
<pre class="text-xs bg-zinc-100 dark:bg-zinc-800 p-3 rounded-md overflow-auto max-h-64 whitespace-pre-wrap break-all">{details}</pre>
{/if}
</div>
<Dialog.Footer class="flex gap-2 sm:justify-end">
<Button onclick={handleClose}>OK</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,3 @@
import ErrorDialog from './error-dialog.svelte';
export { ErrorDialog };
@@ -21,7 +21,7 @@
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-[200] w-72 rounded-md border p-4 shadow-md",
className
)}
{...restProps}
@@ -24,7 +24,7 @@
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-[200] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
@@ -21,7 +21,7 @@
{sideOffset}
{side}
class={cn(
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[100] w-fit fixed text-balance rounded-md px-3 py-1.5 text-xs",
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[200] w-fit fixed text-balance rounded-md px-3 py-1.5 text-xs",
className
)}
{...restProps}
+4 -2
View File
@@ -37,7 +37,8 @@ export const imageTagColumns: ColumnConfig[] = [
{ id: 'id', label: 'ID', width: 120, minWidth: 80 },
{ id: 'size', label: 'Size', width: 80, minWidth: 60 },
{ id: 'created', label: 'Created', width: 140, minWidth: 100 },
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
{ id: 'used', label: 'Used by', width: 100, minWidth: 70 },
{ id: 'actions', label: '', fixed: 'end', width: 200, resizable: false }
];
// Network grid columns
@@ -58,7 +59,8 @@ export const stackColumns: ColumnConfig[] = [
{ id: 'expand', label: '', fixed: 'start', width: 24, resizable: false },
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
{ id: 'status', label: 'Status', sortable: true, sortField: 'status', width: 120, minWidth: 90 },
{ id: 'source', label: 'Source', width: 100, minWidth: 60 },
{ id: 'source', label: 'Source', width: 100, minWidth: 100, noTruncate: true },
{ id: 'location', label: 'Location', width: 180, minWidth: 100 },
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 60, minWidth: 50, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 70, minWidth: 50, align: 'right' },
+68
View File
@@ -1,4 +1,72 @@
[
{
"version": "1.0.8",
"date": "2026-01-13",
"changes": [
{ "type": "fix", "text": "Fix imported stack working directory for relative volume paths" },
{ "type": "fix", "text": "Fix environment refresh after auth login" },
{ "type": "fix", "text": "Fix single container update clearing up all update badges" },
{ "type": "fix", "text": "Fix code editor paste issue on Safari on iPad" },
{ "type": "fix", "text": "Fix registry login failing due to Bun stdin API incompatibility" },
{ "type": "fix", "text": "Fix env var editor focus issues" },
{ "type": "fix", "text": "Fix git stack naming issues: validation, rename sync, and delete cleanup" }
],
"imageTag": "fnsys/dockhand:v1.0.8"
},
{
"version": "1.0.7",
"date": "2026-01-06",
"comingSoon": false,
"changes": [
{ "type": "feature", "text": "Adopt stacks created outside Dockhand" },
{ "type": "feature", "text": "Activity event collection mode (Stream/Poll) and metrics interval settings for reduced CPU usage" },
{ "type": "feature", "text": "Baseline Docker images for CPUs without AVX support" },
{ "type": "feature", "text": "Show amber \"Unused\" badge for images not used by any container" },
{ "type": "feature", "text": "Prune unused button to remove all unused images (not just dangling)" },
{ "type": "fix", "text": "Stack collision on disk - stacks are now saved in environment folders" },
{ "type": "fix", "text": "Checkbox selection delay in datagrid" },
{ "type": "fix", "text": "Crypto fallback for old Linux kernels (<3.17) that lack getrandom() syscall" },
{ "type": "fix", "text": "Dashboard performance with many environments" },
{ "type": "fix", "text": "Can't use authenticated custom registry"},
{ "type": "fix", "text": "mTLS connections failing due to Bun TLS caching bug"}
],
"imageTag": "fnsys/dockhand:v1.0.7"
},
{
"version": "1.0.6",
"date": "2026-01-03",
"changes": [
{ "type": "fix", "text": "Legacy CPU support (Celeron, Atom) - Bun binary now copied from official image instead of Wolfi package" },
{ "type": "fix", "text": "Stack modal layouts improved with resizable split panels" },
{ "type": "fix", "text": "Missing column headers in images overview" }
],
"imageTag": "fnsys/dockhand:v1.0.6"
},
{
"version": "1.0.5",
"date": "2026-01-01",
"changes": [
{ "type": "feature", "text": "Custom hardened image built from scratch using Wolfi packages, eliminating Alpine vulnerabilities" },
{ "type": "feature", "text": "Clicking container name opens container details" },
{ "type": "feature", "text": "Clicking stack name opens stack editor (internal stacks)" },
{ "type": "feature", "text": "Stack env editor now supports freestyle text entry for pasting env contents" },
{ "type": "feature", "text": "Stack env vars saved as .env file next to compose, respecting external edits" },
{ "type": "feature", "text": "Additional container options: ulimits, security options, DNS settings" },
{ "type": "fix", "text": "DataGrid performance and memory leak on Activity page with thousands of rows" },
{ "type": "fix", "text": "Webhook endpoints bypass session authentication when auth is enabled" },
{ "type": "fix", "text": "PUID 1000 conflict with existing dockhand user in container" },
{ "type": "fix", "text": "Gmail SMTP notification errors" },
{ "type": "fix", "text": "More detailed error messages when stack fails to start" },
{ "type": "fix", "text": "Container startup with user: directive in compose" },
{ "type": "fix", "text": "Stack editor flickering when typing fast" },
{ "type": "fix", "text": "Container unhealthy notifications not triggering" },
{ "type": "fix", "text": "tlsSkipVerify not being saved in environment settings" },
{ "type": "fix", "text": "MFA available to all users without enterprise license" },
{ "type": "fix", "text": "Socket proxy documentation and examples" },
{ "type": "fix", "text": "Edit container modal reloading during editing" }
],
"imageTag": "fnsys/dockhand:v1.0.5"
},
{
"version": "1.0.4",
"date": "2025-12-28",
+44 -278
View File
@@ -11,6 +11,12 @@
"license": "MIT",
"repository": "https://github.com/codemirror/commands"
},
{
"name": "@codemirror/commands",
"version": "6.10.1",
"license": "MIT",
"repository": "https://github.com/codemirror/commands"
},
{
"name": "@codemirror/lang-css",
"version": "6.3.1",
@@ -59,9 +65,15 @@
"license": "MIT",
"repository": "https://github.com/codemirror/lang-xml"
},
{
"name": "@codemirror/lang-yaml",
"version": "6.1.2",
"license": "MIT",
"repository": "https://github.com/codemirror/lang-yaml"
},
{
"name": "@codemirror/language",
"version": "6.11.3",
"version": "6.12.1",
"license": "MIT",
"repository": "https://github.com/codemirror/language"
},
@@ -79,13 +91,19 @@
},
{
"name": "@codemirror/state",
"version": "6.5.2",
"version": "6.5.3",
"license": "MIT",
"repository": "https://github.com/codemirror/state"
},
{
"name": "@codemirror/theme-one-dark",
"version": "6.1.3",
"license": "MIT",
"repository": "https://github.com/codemirror/theme-one-dark"
},
{
"name": "@codemirror/view",
"version": "6.38.8",
"version": "6.39.9",
"license": "MIT",
"repository": "https://github.com/codemirror/view"
},
@@ -121,7 +139,7 @@
},
{
"name": "@lezer/common",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"repository": "https://github.com/lezer-parser/common"
},
@@ -179,6 +197,12 @@
"license": "MIT",
"repository": "https://github.com/lezer-parser/xml"
},
{
"name": "@lezer/yaml",
"version": "1.0.3",
"license": "MIT",
"repository": "https://github.com/lezer-parser/yaml"
},
{
"name": "@lucide/lab",
"version": "0.1.2",
@@ -203,18 +227,6 @@
"license": "MIT",
"repository": "https://github.com/sveltejs/acorn-typescript"
},
{
"name": "@types/asn1",
"version": "0.2.4",
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "@types/better-sqlite3",
"version": "7.6.13",
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "@types/estree",
"version": "1.0.8",
@@ -257,51 +269,15 @@
"license": "Apache-2.0",
"repository": "https://github.com/A11yance/aria-query"
},
{
"name": "asn1",
"version": "0.2.6",
"license": "MIT",
"repository": "https://github.com/joyent/node-asn1"
},
{
"name": "axobject-query",
"version": "4.1.0",
"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": "12.5.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.3",
"version": "1.3.5",
"license": "MIT",
"repository": "https://github.com/oven-sh/bun"
},
@@ -311,12 +287,6 @@
"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",
@@ -329,6 +299,12 @@
"license": "MIT",
"repository": "https://github.com/lukeed/clsx"
},
{
"name": "codemirror",
"version": "6.0.2",
"license": "MIT",
"repository": "https://github.com/codemirror/basic-setup"
},
{
"name": "color-convert",
"version": "2.0.1",
@@ -359,36 +335,12 @@
"license": "MIT",
"repository": "https://github.com/bradymholt/cronstrue"
},
{
"name": "debug",
"version": "4.4.3",
"license": "MIT",
"repository": "https://github.com/debug-js/debug"
},
{
"name": "decamelize",
"version": "1.2.0",
"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.5.0",
@@ -409,7 +361,7 @@
},
{
"name": "drizzle-orm",
"version": "0.45.0",
"version": "0.45.1",
"license": "Apache-2.0",
"repository": "https://github.com/drizzle-team/drizzle-orm"
},
@@ -419,12 +371,6 @@
"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",
@@ -437,30 +383,12 @@
"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",
@@ -468,28 +396,10 @@
"repository": "https://github.com/stefanpenner/get-caller-file"
},
{
"name": "github-from-package",
"version": "0.0.0",
"name": "hash-wasm",
"version": "4.12.0",
"license": "MIT",
"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"
"repository": "https://github.com/Daninet/hash-wasm"
},
{
"name": "is-fullwidth-code-point",
@@ -511,7 +421,7 @@
},
{
"name": "ldapts",
"version": "8.0.12",
"version": "8.1.3",
"license": "MIT",
"repository": "https://github.com/ldapts/ldapts"
},
@@ -533,54 +443,12 @@
"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": "ms",
"version": "2.1.3",
"license": "MIT",
"repository": "https://github.com/vercel/ms"
},
{
"name": "napi-build-utils",
"version": "2.0.0",
"license": "MIT",
"repository": "https://github.com/inspiredware/napi-build-utils"
},
{
"name": "node-abi",
"version": "3.85.0",
"license": "MIT",
"repository": "https://github.com/electron/node-abi"
},
{
"name": "nodemailer",
"version": "7.0.11",
"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",
@@ -619,22 +487,10 @@
},
{
"name": "postgres",
"version": "3.4.7",
"version": "3.4.8",
"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",
@@ -647,18 +503,6 @@
"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",
@@ -677,42 +521,12 @@
"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": "safer-buffer",
"version": "2.1.2",
"license": "MIT",
"repository": "https://github.com/ChALkeR/safer-buffer"
},
{
"name": "semver",
"version": "7.7.3",
"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",
@@ -725,24 +539,12 @@
"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",
@@ -751,13 +553,13 @@
},
{
"name": "svelte",
"version": "5.45.5",
"version": "5.46.1",
"license": "MIT",
"repository": "https://github.com/sveltejs/svelte"
},
{
"name": "svelte-dnd-action",
"version": "0.9.68",
"version": "0.9.69",
"license": "MIT",
"repository": "https://github.com/isaacHagoel/svelte-dnd-action"
},
@@ -767,48 +569,18 @@
"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": "uuid",
"version": "13.0.0",
"license": "MIT",
"repository": "https://github.com/uuidjs/uuid"
},
{
"name": "w3c-keyname",
"version": "2.2.8",
@@ -839,12 +611,6 @@
"license": "MIT",
"repository": "https://github.com/chalk/wrap-ansi"
},
{
"name": "wrappy",
"version": "1.0.2",
"license": "ISC",
"repository": "https://github.com/npm/wrappy"
},
{
"name": "y18n",
"version": "4.0.3",
+162 -23
View File
@@ -9,8 +9,9 @@
* - SameSite=Strict (CSRF protection)
*/
import { randomBytes } from 'node:crypto';
import os from 'node:os';
import { secureRandomBytes, usingFallback } from './crypto-fallback';
import { argon2id, argon2Verify } from 'hash-wasm';
import type { Cookies } from '@sveltejs/kit';
import {
getAuthSettings,
@@ -94,27 +95,62 @@ export interface LoginResult {
}
// ============================================
// Password Hashing (Argon2id via Bun.password)
// Password Hashing (Argon2id)
// ============================================
// Argon2id parameters (matching Bun.password defaults)
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 via Bun's native password API
* 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
*/
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',
memoryCost: 65536, // 64 MB
timeCost: 3 // 3 iterations
memoryCost: ARGON2_MEMORY_COST,
timeCost: ARGON2_TIME_COST
});
}
/**
* 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
*/
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 false;
@@ -130,7 +166,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
* 32 bytes = 256 bits of entropy
*/
function generateSessionToken(): string {
return randomBytes(32).toString('base64url');
return secureRandomBytes(32).toString('base64url');
}
/**
@@ -411,7 +447,7 @@ export async function authenticateLocal(
if (!user) {
// Use constant time to prevent timing attacks
await Bun.password.hash('dummy', { algorithm: 'argon2id' });
await hashPassword('dummy');
return { success: false, error: 'Invalid username or password' };
}
@@ -747,12 +783,70 @@ function getAttributeValue(entry: any, attribute: string): string | undefined {
}
// ============================================
// MFA (TOTP)
// MFA (TOTP) with Backup Codes
// ============================================
import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode';
// MFA data stored in mfaSecret field as JSON
interface MfaData {
secret: string; // TOTP secret (base32)
backupCodes: string[]; // Hashed backup codes (unused ones)
}
/**
* Generate 10 random backup codes (8 characters each, alphanumeric)
*/
function generateBackupCodes(): string[] {
const codes: string[] = [];
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusable chars: 0, O, 1, I
for (let i = 0; i < 10; i++) {
let code = '';
for (let j = 0; j < 8; j++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
codes.push(code);
}
return codes;
}
/**
* Hash a backup code for storage
*/
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');
}
/**
* Parse MFA data from database field
*/
function parseMfaData(mfaSecret: string | null | undefined): MfaData | null {
if (!mfaSecret) return null;
try {
// Try parsing as JSON first (new format)
const parsed = JSON.parse(mfaSecret);
if (parsed && typeof parsed.secret === 'string') {
return {
secret: parsed.secret,
backupCodes: parsed.backupCodes || []
};
}
} catch {
// Legacy format: plain base32 secret string
return {
secret: mfaSecret,
backupCodes: []
};
}
return null;
}
/**
* Generate MFA secret and QR code for setup
*/
@@ -787,18 +881,24 @@ export async function generateMfaSetup(userId: number): Promise<{
margin: 2
});
// Store secret temporarily (user must verify before it's enabled)
await updateUser(userId, { mfaSecret: secretBase32 });
// Store secret temporarily as JSON (user must verify before it's enabled)
// Backup codes will be generated after verification
const mfaData: MfaData = { secret: secretBase32, backupCodes: [] };
await updateUser(userId, { mfaSecret: JSON.stringify(mfaData) });
return { secret: secretBase32, qrDataUrl };
}
/**
* Verify MFA token and enable MFA if valid
* Returns backup codes on success (shown only once)
*/
export async function verifyAndEnableMfa(userId: number, token: string): Promise<boolean> {
export async function verifyAndEnableMfa(userId: number, token: string): Promise<{ success: false } | { success: true; backupCodes: string[] }> {
const user = await getUser(userId);
if (!user || !user.mfaSecret) return false;
if (!user || !user.mfaSecret) return { success: false };
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return { success: false };
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
@@ -806,35 +906,74 @@ export async function verifyAndEnableMfa(userId: number, token: string): Promise
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
if (delta === null) return false;
if (delta === null) return { success: false };
// Enable MFA
await updateUser(userId, { mfaEnabled: true });
return true;
// Generate backup codes
const plainBackupCodes = generateBackupCodes();
const hashedBackupCodes = await Promise.all(plainBackupCodes.map(hashBackupCode));
// Update MFA data with hashed backup codes and enable MFA
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: hashedBackupCodes
};
await updateUser(userId, {
mfaEnabled: true,
mfaSecret: JSON.stringify(updatedMfaData)
});
// Return plain backup codes (shown only once)
return { success: true, backupCodes: plainBackupCodes };
}
/**
* Verify MFA token during login
* Verify MFA token during login (accepts TOTP code or backup code)
*/
export async function verifyMfaToken(userId: number, token: string): Promise<boolean> {
const user = await getUser(userId);
if (!user || !user.mfaEnabled || !user.mfaSecret) return false;
const mfaData = parseMfaData(user.mfaSecret);
if (!mfaData) return false;
// First, try TOTP verification
const totp = new OTPAuth.TOTP({
issuer: 'Dockhand',
label: user.username,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
if (delta !== null) return true;
// If TOTP fails, try backup code
if (mfaData.backupCodes && mfaData.backupCodes.length > 0) {
const hashedInput = await hashBackupCode(token);
const codeIndex = mfaData.backupCodes.indexOf(hashedInput);
if (codeIndex !== -1) {
// Remove used backup code
const updatedBackupCodes = [...mfaData.backupCodes];
updatedBackupCodes.splice(codeIndex, 1);
const updatedMfaData: MfaData = {
secret: mfaData.secret,
backupCodes: updatedBackupCodes
};
await updateUser(userId, { mfaSecret: JSON.stringify(updatedMfaData) });
return true;
}
}
return false;
}
/**
@@ -1024,7 +1163,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
* Generate PKCE code verifier and challenge
*/
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = randomBytes(32).toString('base64url');
const codeVerifier = secureRandomBytes(32).toString('base64url');
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(codeVerifier);
const codeChallenge = hasher.digest('base64url') as string;
@@ -1047,8 +1186,8 @@ export async function buildOidcAuthorizationUrl(
const discovery = await getOidcDiscovery(config.issuerUrl);
// Generate state, nonce, and PKCE
const state = randomBytes(32).toString('base64url');
const nonce = randomBytes(16).toString('base64url');
const state = secureRandomBytes(32).toString('base64url');
const nonce = secureRandomBytes(16).toString('base64url');
const { codeVerifier, codeChallenge } = generatePkce();
// Store state for callback verification (expires in 10 minutes)
+199
View File
@@ -0,0 +1,199 @@
/**
* 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".
*
* This module provides fallback implementations that read from /dev/urandom directly
* when running on kernels older than 3.17.
*/
import { existsSync, openSync, readSync, closeSync } from 'node:fs';
import os from 'node:os';
// Cache kernel version check result
let needsFallback: boolean | null = null;
let fallbackInitialized = false;
/**
* Parse Linux kernel version string (e.g., "3.10.108" -> { major: 3, minor: 10, patch: 108 })
*/
function parseKernelVersion(release: string): { major: number; minor: number; patch: number } | null {
const match = release.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10)
};
}
/**
* Check if kernel version is older than 3.17 (when getrandom() was added)
*/
function isOldKernel(): boolean {
const release = os.release();
const version = parseKernelVersion(release);
if (!version) {
// Can't parse version, assume modern kernel
return false;
}
// getrandom() was added in Linux 3.17
if (version.major < 3) return true;
if (version.major === 3 && version.minor < 17) return true;
return false;
}
/**
* Check if we're on Linux (only Linux has kernel version concerns)
*/
function isLinux(): boolean {
return os.platform() === 'linux';
}
/**
* Determine if we need to use the fallback (cached)
*/
function checkNeedsFallback(): boolean {
if (needsFallback !== null) return needsFallback;
if (!isLinux()) {
needsFallback = false;
return false;
}
const oldKernel = isOldKernel();
if (oldKernel) {
console.log(`[Crypto] Detected old Linux kernel (${os.release()}), using /dev/urandom fallback`);
needsFallback = true;
} else {
needsFallback = false;
}
return needsFallback;
}
/**
* Read random bytes from /dev/urandom (synchronous)
*/
function readFromUrandom(size: number): Buffer {
const buffer = Buffer.alloc(size);
const fd = openSync('/dev/urandom', 'r');
try {
readSync(fd, buffer, 0, size, null);
} finally {
closeSync(fd);
}
return buffer;
}
/**
* Initialize the crypto fallback - call this early at startup
* Returns true if fallback is needed, false otherwise
*/
export function initCryptoFallback(): boolean {
if (fallbackInitialized) return needsFallback ?? false;
const release = os.release();
const platform = os.platform();
const useFallback = checkNeedsFallback();
if (useFallback) {
console.log(`[Crypto] Kernel: ${release} (old kernel detected, using /dev/urandom fallback)`);
// Verify /dev/urandom exists
if (!existsSync('/dev/urandom')) {
console.error('[Crypto] FATAL: /dev/urandom not found, cannot provide entropy');
throw new Error('/dev/urandom not available');
}
// Test that we can read from it
try {
const testBytes = readFromUrandom(8);
if (testBytes.length !== 8) {
throw new Error('Failed to read expected bytes');
}
console.log('[Crypto] /dev/urandom fallback initialized successfully');
} catch (err) {
console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', err);
throw err;
}
} else {
console.log(`[Crypto] Kernel: ${platform === 'linux' ? release : platform} (using native crypto)`);
}
fallbackInitialized = true;
return useFallback;
}
/**
* Generate cryptographically secure random bytes
* Uses /dev/urandom on old kernels, native crypto otherwise
*/
export function secureRandomBytes(size: number): Buffer {
if (checkNeedsFallback()) {
return readFromUrandom(size);
}
// Use native crypto on modern kernels
const { randomBytes } = require('node:crypto');
return randomBytes(size);
}
/**
* Fill a Uint8Array with cryptographically secure random values
* Compatible with crypto.getRandomValues() API
*/
export function secureGetRandomValues<T extends ArrayBufferView>(array: T): T {
if (checkNeedsFallback()) {
const bytes = readFromUrandom(array.byteLength);
const target = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
target.set(bytes);
return array;
}
// Use native crypto on modern kernels
return crypto.getRandomValues(array);
}
/**
* Generate a random UUID (v4)
* Compatible with crypto.randomUUID() API
*/
export function secureRandomUUID(): string {
if (checkNeedsFallback()) {
// Generate 16 random bytes
const bytes = readFromUrandom(16);
// Set version (4) and variant (RFC 4122)
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
// Convert to UUID string
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
// Use native crypto on modern kernels
return crypto.randomUUID();
}
/**
* Check if running on an old kernel that needs the fallback
*/
export function usingFallback(): boolean {
return checkNeedsFallback();
}
/**
* Get kernel version info (useful for diagnostics)
*/
export function getKernelInfo(): { release: string; needsFallback: boolean } {
return {
release: os.release(),
needsFallback: checkNeedsFallback()
};
}
+220 -8
View File
@@ -125,6 +125,11 @@ export async function getEnvironment(id: number): Promise<Environment | undefine
return results[0];
}
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
const results = await db.select().from(environments).where(eq(environments.name, name));
return results[0];
}
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
const result = await db.insert(environments).values({
name: env.name,
@@ -156,6 +161,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey;
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
if (env.icon !== undefined) updateData.icon = env.icon;
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
if (env.collectActivity !== undefined) updateData.collectActivity = env.collectActivity;
@@ -808,6 +814,7 @@ export interface SmtpConfig {
from_email: string;
from_name?: string;
to_emails: string[];
skipTlsVerify?: boolean; // Skip TLS certificate verification (useful for self-signed certs)
}
export interface AppriseConfig {
@@ -2485,6 +2492,8 @@ export interface StackSourceData {
sourceType: StackSourceType;
gitRepositoryId: number | null;
gitStackId: number | null;
composePath: string | null;
envPath: string | null;
createdAt: string;
updatedAt: string;
}
@@ -2525,9 +2534,10 @@ export async function getStackSource(stackName: string, environmentId?: number |
export async function getStackSources(environmentId?: number | null): Promise<StackSourceWithRepo[]> {
let results;
if (environmentId !== undefined) {
if (environmentId !== undefined && environmentId !== null) {
// Only get stacks for the specific environment
results = await db.select().from(stackSources)
.where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId)))
.where(eq(stackSources.environmentId, environmentId))
.orderBy(asc(stackSources.stackName));
} else {
results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName));
@@ -2561,6 +2571,8 @@ export async function upsertStackSource(data: {
sourceType: StackSourceType;
gitRepositoryId?: number | null;
gitStackId?: number | null;
composePath?: string | null;
envPath?: string | null;
}): Promise<StackSourceData> {
const existing = await getStackSource(data.stackName, data.environmentId);
@@ -2570,6 +2582,8 @@ export async function upsertStackSource(data: {
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
@@ -2580,12 +2594,33 @@ export async function upsertStackSource(data: {
environmentId: data.environmentId ?? null,
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null
});
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
}
}
export async function updateStackSource(
stackName: string,
environmentId: number | null,
updates: { composePath?: string | null; envPath?: string | null }
): Promise<boolean> {
const existing = await getStackSource(stackName, environmentId);
if (!existing) return false;
await db.update(stackSources)
.set({
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
return true;
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
@@ -2608,6 +2643,25 @@ export async function deleteStackSource(stackName: string, environmentId?: numbe
return true;
}
export async function updateStackSourceName(
oldStackName: string,
newStackName: string,
environmentId?: number | null
): Promise<boolean> {
await db.update(stackSources)
.set({
stackName: newStackName,
updatedAt: new Date().toISOString()
})
.where(and(
eq(stackSources.stackName, oldStackName),
environmentId !== undefined && environmentId !== null
? eq(stackSources.environmentId, environmentId)
: isNull(stackSources.environmentId)
));
return true;
}
// =============================================================================
// VULNERABILITY SCAN RESULTS
// =============================================================================
@@ -3081,10 +3135,8 @@ export interface ContainerEventResult {
}
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
// Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL)
// For PostgreSQL, we convert to Date since the schema uses native timestamp type
const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp;
// 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({
environmentId: data.environmentId ?? null,
containerId: data.containerId,
@@ -3092,7 +3144,7 @@ export async function logContainerEvent(data: ContainerEventCreateData): Promise
image: data.image ?? null,
action: data.action,
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
timestamp
timestamp: data.timestamp
}).returning();
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
@@ -3894,6 +3946,73 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths';
export async function getExternalStackPaths(): Promise<string[]> {
const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
if (result[0]) {
try {
const parsed = JSON.parse(result[0].value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export async function setExternalStackPaths(paths: string[]): Promise<void> {
const jsonValue = JSON.stringify(paths);
const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: jsonValue, updatedAt: new Date().toISOString() })
.where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
} else {
await db.insert(settings).values({
key: EXTERNAL_STACK_PATHS_KEY,
value: jsonValue
});
}
}
// =============================================================================
// PRIMARY STACK LOCATION
// =============================================================================
const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location';
export async function getPrimaryStackLocation(): Promise<string | null> {
const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
if (result[0]?.value) {
return result[0].value;
}
return null;
}
export async function setPrimaryStackLocation(path: string | null): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
if (path === null) {
// Delete the setting if path is null
if (existing.length > 0) {
await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
}
} else if (existing.length > 0) {
await db.update(settings)
.set({ value: path, updatedAt: new Date().toISOString() })
.where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
} else {
await db.insert(settings).values({
key: PRIMARY_STACK_LOCATION_KEY,
value: path
});
}
}
// =============================================================================
// ENVIRONMENT UPDATE CHECK SETTINGS
// =============================================================================
@@ -3986,6 +4105,66 @@ export async function setDefaultTimezone(timezone: string): Promise<void> {
await setSetting('default_timezone', timezone);
}
// =============================================================================
// BACKGROUND MONITORING SETTINGS
// =============================================================================
/**
* Get event collection mode ('stream' or 'poll').
* Defaults to 'stream' for real-time event streaming.
*/
export async function getEventCollectionMode(): Promise<'stream' | 'poll'> {
const value = await getSetting('event_collection_mode');
return value || 'stream';
}
/**
* Set event collection mode.
*/
export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise<void> {
await setSetting('event_collection_mode', mode);
}
/**
* Get event poll interval in milliseconds.
* Defaults to 60000ms (60 seconds).
*/
export async function getEventPollInterval(): Promise<number> {
const value = await getSetting('event_poll_interval');
return value || 60000;
}
/**
* Set event poll interval in milliseconds.
* Valid range: 30000ms (30s) to 300000ms (5min).
*/
export async function setEventPollInterval(interval: number): Promise<void> {
if (interval < 30000 || interval > 300000) {
throw new Error('Event poll interval must be between 30s and 300s');
}
await setSetting('event_poll_interval', interval);
}
/**
* Get metrics collection interval in milliseconds.
* Defaults to 30000ms (30 seconds) - changed from hardcoded 10s.
*/
export async function getMetricsCollectionInterval(): Promise<number> {
const value = await getSetting('metrics_collection_interval');
return value || 30000;
}
/**
* Set metrics collection interval in milliseconds.
* Valid range: 10000ms (10s) to 300000ms (5min).
*/
export async function setMetricsCollectionInterval(interval: number): Promise<void> {
if (interval < 10000 || interval > 300000) {
throw new Error('Metrics collection interval must be between 10s and 300s');
}
await setSetting('metrics_collection_interval', interval);
}
// =============================================================================
// STACK ENVIRONMENT VARIABLES OPERATIONS
// =============================================================================
@@ -4062,6 +4241,39 @@ export async function getStackEnvVarsAsRecord(
return Object.fromEntries(vars.map(v => [v.key, v.value]));
}
/**
* Get only SECRET environment variables as a key-value record (for shell injection).
* Returns unmasked real values - used to inject secrets via shell environment at runtime.
* These secrets are NEVER written to .env files on disk.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getSecretEnvVarsAsRecord(
stackName: string,
environmentId?: number | null
): Promise<Record<string, string>> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return Object.fromEntries(
vars.filter(v => v.isSecret).map(v => [v.key, v.value])
);
}
/**
* Get only NON-SECRET environment variables as a key-value record.
* Used for .env file operations where secrets should be excluded.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getNonSecretEnvVarsAsRecord(
stackName: string,
environmentId?: number | null
): Promise<Record<string, string>> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return Object.fromEntries(
vars.filter(v => !v.isSecret).map(v => [v.key, v.value])
);
}
/**
* Set/replace all environment variables for a stack.
* Deletes existing vars and inserts new ones in a transaction-like manner.
+4 -4
View File
@@ -604,21 +604,21 @@ async function initializeDatabase() {
logHeader('DATABASE INITIALIZATION');
if (isPostgres) {
// PostgreSQL via Bun.sql
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
validatePostgresUrl(config.databaseUrl!);
logInfo(`Database: PostgreSQL`);
logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`);
const { drizzle } = await import('drizzle-orm/bun-sql');
const { SQL } = await import('bun');
const { drizzle } = await import('drizzle-orm/postgres-js');
const postgres = (await import('postgres')).default;
// Import PostgreSQL schema
schema = await import('./schema/pg-schema.js');
if (verbose) logStep('Connecting to PostgreSQL...');
try {
rawClient = new SQL(config.databaseUrl!);
rawClient = postgres(config.databaseUrl!);
db = drizzle({ client: rawClient, schema });
logSuccess('PostgreSQL connection established');
} catch (error) {
+3 -1
View File
@@ -181,7 +181,7 @@ export const users = sqliteTable('users', {
avatar: text('avatar'),
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
mfaEnabled: integer('mfa_enabled', { mode: 'boolean' }).default(false),
mfaSecret: text('mfa_secret'),
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
isActive: integer('is_active', { mode: 'boolean' }).default(true),
lastLogin: text('last_login'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
@@ -332,6 +332,8 @@ export const stackSources = sqliteTable('stack_sources', {
sourceType: text('source_type').notNull().default('internal'),
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
+3 -1
View File
@@ -184,7 +184,7 @@ export const users = pgTable('users', {
avatar: text('avatar'),
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
mfaEnabled: boolean('mfa_enabled').default(false),
mfaSecret: text('mfa_secret'),
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
isActive: boolean('is_active').default(true),
lastLogin: timestamp('last_login', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
@@ -335,6 +335,8 @@ export const stackSources = pgTable('stack_sources', {
sourceType: text('source_type').notNull().default('internal'),
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
+111 -44
View File
@@ -469,13 +469,16 @@ export async function dockerFetch(
streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests
);
const elapsed = Date.now() - startTime;
if (elapsed > 5000) {
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`);
}
return edgeResponseToResponse(edgeResponse);
} catch (error) {
} catch (error: any) {
const elapsed = Date.now() - startTime;
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error);
// Log error message only, not full stack trace
const msg = error?.message || String(error);
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
}
@@ -491,13 +494,16 @@ export async function dockerFetch(
...bunOptions
});
const elapsed = Date.now() - startTime;
if (elapsed > 5000) {
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`);
}
return response;
} catch (error) {
} catch (error: any) {
const elapsed = Date.now() - startTime;
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error);
// Log error message only, not full stack trace
const msg = error?.message || String(error);
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
} else {
@@ -516,21 +522,30 @@ export async function dockerFetch(
}
// For HTTPS with TLS certificates, we need to configure TLS
// IMPORTANT: Bun requires certificates as Buffer objects, not strings
// Pass certificate strings directly to Bun's fetch - no temp files needed
if (config.type === 'https') {
const tlsOptions: Record<string, unknown> = {};
// CA certificate - must be array of Buffers for Bun
// DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts,
// which causes client certificate mismatches in mTLS scenarios. By setting
// sessionTimeout to 0, we force a fresh TLS handshake for every connection.
tlsOptions.sessionTimeout = 0;
// Set explicit servername for SNI - helps isolate TLS contexts per host
tlsOptions.servername = config.host;
// Load CA certificate (just this environment's CA, not composite)
// The sessionTimeout=0 should prevent session reuse across hosts
if (config.ca) {
tlsOptions.ca = [Buffer.from(config.ca)];
tlsOptions.ca = [config.ca];
}
// Client certificate and key for mTLS - must be Buffers
// Client cert and key for mTLS authentication
if (config.cert) {
tlsOptions.cert = Buffer.from(config.cert);
tlsOptions.cert = [config.cert];
}
if (config.key) {
tlsOptions.key = Buffer.from(config.key);
tlsOptions.key = config.key;
}
// Skip verification (self-signed without CA)
@@ -541,8 +556,27 @@ export async function dockerFetch(
}
if (Object.keys(tlsOptions).length > 0) {
// @ts-ignore - Bun supports tls options with Buffer certs
// @ts-ignore - Bun supports tls options with string certs
finalOptions.tls = tlsOptions;
// Force new connection for each request to prevent Bun from reusing
// a TLS session with wrong client certificates (pool key doesn't include certs)
// @ts-ignore - Bun supports keepalive option
finalOptions.keepalive = false;
}
// Explicitly close connection to prevent TLS session reuse issues
// But only for non-streaming requests (logs, events, exec need keep-alive)
if (!streaming) {
finalOptions.headers = {
...finalOptions.headers,
'Connection': 'close'
};
}
// Optional verbose TLS debugging
if (process.env.DEBUG_TLS) {
// @ts-ignore - Bun-specific verbose option
finalOptions.verbose = true;
}
}
@@ -550,13 +584,16 @@ export async function dockerFetch(
try {
const response = await fetch(url, { ...finalOptions, ...bunOptions });
const elapsed = Date.now() - startTime;
if (elapsed > 5000) {
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
}
return response;
} catch (error) {
} catch (error: any) {
const elapsed = Date.now() - startTime;
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error);
// Log error message only, not full stack trace
const msg = error?.message || String(error);
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
}
@@ -677,10 +714,13 @@ export async function listContainers(all = true, envId?: number | null): Promise
}));
// Extract health status from Status string
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
let health: string | undefined;
const healthMatch = container.Status?.match(/\((healthy|unhealthy|starting)\)/i);
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
if (healthMatch) {
health = healthMatch[1].toLowerCase();
const matched = healthMatch[1].toLowerCase();
// Normalize "health: starting" to just "starting"
health = matched.includes('starting') ? 'starting' : matched;
}
return {
@@ -803,6 +843,7 @@ export interface CreateContainerOptions {
labels?: { [key: string]: string };
cmd?: string[];
restartPolicy?: string;
restartMaxRetries?: number;
networkMode?: string;
networks?: string[];
user?: string;
@@ -831,7 +872,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
Labels: options.labels || {},
HostConfig: {
RestartPolicy: {
Name: options.restartPolicy || 'no'
Name: options.restartPolicy || 'no',
...(options.restartPolicy === 'on-failure' && options.restartMaxRetries !== undefined
? { MaximumRetryCount: options.restartMaxRetries }
: {})
}
}
};
@@ -888,9 +932,9 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
if (options.networks && options.networks.length > 0) {
containerConfig.HostConfig.NetworkMode = options.networks[0];
containerConfig.NetworkingConfig = {
EndpointsConfig: {
[options.networks[0]]: {}
}
EndpointsConfig: Object.fromEntries(
options.networks.map(network => [network, {}])
)
};
}
@@ -963,21 +1007,6 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
envId
);
// Connect to additional networks after container creation
if (options.networks && options.networks.length > 1) {
for (let i = 1; i < options.networks.length; i++) {
await dockerFetch(
`/networks/${options.networks[i]}/connect`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Container: result.Id })
},
envId
);
}
}
return { id: result.Id, start: () => startContainer(result.Id, envId) };
}
@@ -1002,12 +1031,31 @@ export async function updateContainer(id: string, options: CreateContainerOption
// Image operations
export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
const images = await dockerJsonRequest<any[]>('/images/json', {}, envId);
// Fetch images and containers in parallel
const [images, containers] = await Promise.all([
dockerJsonRequest<any[]>('/images/json', {}, envId),
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId).catch(() => [] as any[])
]);
// Build a map of imageId -> container count
// Docker may return -1 for Containers field on some hosts, so we compute it ourselves
const imageContainerCount = new Map<string, number>();
for (const container of containers) {
const imageId = container.ImageID || container.Image;
if (imageId) {
imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1);
}
}
return images.map((image) => ({
id: image.Id,
repoTags: image.RepoTags || [],
tags: image.RepoTags || [],
size: image.Size,
created: image.Created
virtualSize: image.VirtualSize || image.Size,
created: image.Created,
labels: image.Labels || {},
containers: imageContainerCount.get(image.Id) || 0
}));
}
@@ -1951,7 +1999,10 @@ export async function pruneContainers(envId?: number | null) {
}
export async function pruneImages(dangling = true, envId?: number | null) {
const filters = dangling ? '{"dangling":["true"]}' : '{}';
// dangling=true: only remove untagged images (default Docker behavior)
// dangling=false: remove ALL unused images including tagged ones
// Docker API quirk: to remove all unused, we pass dangling=false filter
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
}
@@ -2050,18 +2101,30 @@ export async function execInContainer(
}
// Get Docker events as a stream (for SSE)
// For streaming mode: call with just filters
// For polling mode: call with since and until to get a finite window of events
export async function getDockerEvents(
filters: Record<string, string[]>,
envId?: number | null
envId?: number | null,
options?: { since?: string; until?: string }
): Promise<ReadableStream<Uint8Array> | null> {
const filterJson = JSON.stringify(filters);
// Build query string with optional since/until for polling mode
let queryString = `filters=${encodeURIComponent(filterJson)}`;
if (options?.since) {
queryString += `&since=${encodeURIComponent(options.since)}`;
}
if (options?.until) {
queryString += `&until=${encodeURIComponent(options.until)}`;
}
try {
// Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection.
// The Docker events API keeps the connection open indefinitely, sending events as they occur.
// Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity.
const response = await dockerFetch(
`/events?filters=${encodeURIComponent(filterJson)}`,
`/events?${queryString}`,
{ streaming: true },
envId
);
@@ -3122,8 +3185,12 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise<n
}
return removed;
} catch (err) {
console.warn('Failed to query stale volume helpers:', err);
} catch (err: any) {
// Don't spam logs for expected failures (e.g., edge agent offline)
const msg = err?.message || String(err);
if (!msg.includes('not connected') && !msg.includes('offline')) {
console.warn('Failed to query stale volume helpers:', msg);
}
return 0;
}
}
+26 -5
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve, dirname } from 'node:path';
import {
getGitRepository,
getGitCredential,
@@ -58,7 +58,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
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}`));
await Bun.write(sshKeyPath, credential.sshPrivateKey);
// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
let keyContent = credential.sshPrivateKey;
if (!keyContent.endsWith('\n')) {
keyContent += '\n';
}
await Bun.write(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
chmodSync(sshKeyPath, 0o600);
@@ -134,7 +141,9 @@ export interface SyncResult {
success: boolean;
commit?: string;
composeContent?: string;
composeDir?: string; // Directory containing the compose file (for copying all files)
envFileVars?: Record<string, string>; // Variables from .env file in repo
envFileContent?: string; // Raw .env file content (for Hawser deployments)
error?: string;
updated?: boolean;
}
@@ -609,16 +618,21 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
console.log(`${logPrefix} Compose directory:`, composeDir);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
let envFileContent: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
if (existsSync(envFilePath)) {
try {
console.log(`${logPrefix} Reading env file...`);
const envContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
envFileContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
} catch (err) {
// Log but don't fail - env file is optional
@@ -653,6 +667,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
success: true,
commit: currentCommit,
composeContent,
composeDir,
envFileVars,
updated
};
@@ -719,11 +734,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// This ensures containers pick up new env var values even if compose file didn't change
// Note: Without this, docker compose only detects compose file changes, not env var changes
console.log(`${logPrefix} Calling deployStack...`);
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
const result = await deployStack({
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
forceRecreate
});
@@ -917,6 +934,9 @@ export async function deployGitStackWithProgress(
const composeContent = await Bun.file(composePath).text();
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
if (gitStack.envFilePath) {
@@ -951,7 +971,8 @@ export async function deployGitStackWithProgress(
name: gitStack.stackName,
compose: composeContent,
envId: gitStack.environmentId,
envFileVars
envFileVars,
sourceDir: composeDir // Copy entire directory from git repo
});
if (result.success) {
+8 -10
View File
@@ -9,6 +9,8 @@ import { db, hawserTokens, environments, eq } from './db/drizzle.js';
import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
// Protocol constants
export const HAWSER_PROTOCOL_VERSION = '1.0';
@@ -243,7 +245,7 @@ export async function validateHawserToken(
// Check each token (tokens are hashed)
for (const t of tokens) {
try {
const isValid = await Bun.password.verify(token, t.token);
const isValid = await verifyPassword(token, t.token);
if (isValid) {
// Update last used timestamp
await db
@@ -292,16 +294,12 @@ export async function generateHawserToken(
} else {
// Generate a secure random token (32 bytes = 256 bits)
const tokenBytes = new Uint8Array(32);
crypto.getRandomValues(tokenBytes);
secureGetRandomValues(tokenBytes);
token = Buffer.from(tokenBytes).toString('base64url');
}
// Hash the token for storage (using Bun's built-in Argon2id)
const hashedToken = await Bun.password.hash(token, {
algorithm: 'argon2id',
memoryCost: 19456,
timeCost: 2
});
// Hash the token for storage (using Argon2id)
const hashedToken = await hashPassword(token);
// Get prefix for identification
const tokenPrefix = token.substring(0, 8);
@@ -477,7 +475,7 @@ export async function sendEdgeRequest(
throw new Error('Edge agent not connected');
}
const requestId = crypto.randomUUID();
const requestId = secureRandomUUID();
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
@@ -614,7 +612,7 @@ export function sendEdgeStreamRequest(
return { requestId: '', cancel: () => {} };
}
const requestId = crypto.randomUUID();
const requestId = secureRandomUUID();
// Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR)
if (!connection.pendingStreamRequests) {
-271
View File
@@ -1,271 +0,0 @@
import { saveHostMetric, getEnvironments, getEnvSetting } from './db';
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker';
import { sendEventNotification } from './notifications';
import os from 'node:os';
const COLLECT_INTERVAL = 10000; // 10 seconds
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
let collectorInterval: ReturnType<typeof setInterval> | null = null;
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
// 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
/**
* Collect metrics for a single environment
*/
async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) {
try {
// Skip environments where metrics collection is disabled
if (env.collectMetrics === false) {
return;
}
// Get running containers
const containers = await listContainers(false, env.id); // Only running
let totalCpuPercent = 0;
let totalMemUsed = 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
const memUsage = stats.memory_stats?.usage || 0;
const memCache = stats.memory_stats?.stats?.cache || 0;
// Subtract cache from usage to get actual memory used by the container
const actualMemUsed = memUsage - memCache;
return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage };
} catch {
return { cpu: 0, mem: 0 };
}
});
const statsResults = await Promise.all(statsPromises);
totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0);
totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0);
// Get host total memory from Docker info (this is the remote host's memory)
const info = await getDockerInfo(env.id) as any;
const memTotal = info.MemTotal || os.totalmem();
// Calculate memory percentage based on container usage vs host total
const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0;
// Normalize CPU by number of cores from the remote host
const cpuCount = info.NCPU || os.cpus().length;
const normalizedCpu = totalCpuPercent / cpuCount;
// Save to database
await saveHostMetric(
normalizedCpu,
memPercent,
totalMemUsed,
memTotal,
env.id
);
} catch (error) {
// Skip this environment if it fails (might be offline)
console.error(`Failed to collect metrics for ${env.name}:`, error);
}
}
async function collectMetrics() {
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 for better performance
await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env)));
} catch (error) {
console.error('Metrics collection error:', error);
}
}
/**
* Check disk space for a single environment
*/
async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) {
try {
// Skip environments where metrics collection is disabled
if (env.collectMetrics === false) {
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;
}
}
}
// If we found total disk space, calculate percentage
if (dataSpaceTotal > 0) {
diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
} else {
// Fallback: just report absolute usage if we can't determine percentage
const GB = 1024 * 1024 * 1024;
if (totalUsed > 50 * GB) {
await sendEventNotification('disk_space_warning', {
title: 'High Docker disk usage',
message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`,
type: 'warning'
}, env.id);
lastDiskWarning.set(env.id, Date.now());
}
return;
}
// Check against threshold
const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD;
if (diskPercentUsed >= threshold) {
console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`);
await sendEventNotification('disk_space_warning', {
title: 'Disk space warning',
message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
type: 'warning'
}, env.id);
lastDiskWarning.set(env.id, Date.now());
}
} catch (error) {
// Skip this environment if it fails
console.error(`Failed to check disk space for ${env.name}:`, error);
}
}
/**
* Check Docker disk usage and send warnings if above threshold
*/
async function checkDiskSpace() {
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 for better performance
await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env)));
} catch (error) {
console.error('Disk space check error:', error);
}
}
/**
* 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]}`;
}
export function startMetricsCollector() {
if (collectorInterval) return; // Already running
console.log('Starting server-side metrics collector (every 10s)');
// Initial collection
collectMetrics();
// Schedule regular collection
collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
// Start disk space checking (every 5 minutes)
console.log('Starting disk space monitoring (every 5 minutes)');
checkDiskSpace(); // Initial check
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
}
export function stopMetricsCollector() {
if (collectorInterval) {
clearInterval(collectorInterval);
collectorInterval = null;
}
if (diskCheckInterval) {
clearInterval(diskCheckInterval);
diskCheckInterval = null;
}
lastDiskWarning.clear();
console.log('Metrics collector stopped');
}
+3
View File
@@ -27,6 +27,9 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
+508
View File
@@ -0,0 +1,508 @@
/**
* Stack Scanner Service
*
* Scans external filesystem paths for Docker Compose files and adopts them as stacks.
* Discovered stacks are editable - compose and .env files are modified in their original location.
*/
import { readdirSync, existsSync, statSync } from 'node:fs';
import { join, basename, dirname, resolve } from 'node:path';
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
// Compose file patterns to detect (in order of priority)
const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
// Directories to skip during scanning
const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv'];
// Maximum recursion depth to prevent runaway scanning
const MAX_DEPTH = 5;
export interface RunningStackInfo {
envId: number;
envName: string;
containerCount: number;
}
export interface DiscoveredStack {
name: string;
composePath: string;
envPath: string | null;
sourceDir: string;
serviceCount?: number; // Number of services defined in compose file
runningOn?: RunningStackInfo[];
}
export interface ScanResult {
discovered: DiscoveredStack[];
adopted: string[];
skipped: DiscoveredStack[];
errors: { path: string; error: string }[];
}
/**
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
*/
function normalizeStackName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Check if a file looks like a compose file (contains 'services:' key)
*/
async function isComposeFile(filePath: string): Promise<boolean> {
try {
const file = Bun.file(filePath);
const content = await file.text();
// Basic check for services key - could be more sophisticated
return /^services:/m.test(content) || /\nservices:/m.test(content);
} catch {
return false;
}
}
/**
* Count the number of services defined in a compose file
* Uses simple regex to count top-level keys under 'services:' section
*/
async function countServices(filePath: string): Promise<number> {
try {
const file = Bun.file(filePath);
const content = await file.text();
// Find the services section and count top-level keys
const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) ||
content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
if (!servicesMatch) return 0;
const servicesBlock = servicesMatch[1];
// Count lines that start with exactly 2 spaces followed by a non-space (service names)
const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm);
return serviceLines?.length || 0;
} catch {
return 0;
}
}
/**
* Scan a single directory path for compose files
*/
async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[]; errors: { path: string; error: string }[] }> {
const discovered: DiscoveredStack[] = [];
const errors: { path: string; error: string }[] = [];
// Resolve to absolute path
const absolutePath = resolve(basePath);
// Verify path exists and is a directory
if (!existsSync(absolutePath)) {
errors.push({ path: basePath, error: 'Path does not exist' });
return { stacks: discovered, errors };
}
try {
const stat = statSync(absolutePath);
if (!stat.isDirectory()) {
errors.push({ path: basePath, error: 'Path is not a directory' });
return { stacks: discovered, errors };
}
} catch (err) {
errors.push({ path: basePath, error: 'Cannot access path' });
return { stacks: discovered, errors };
}
// Track which directories we've found compose files in (to avoid duplicate scanning)
const foundStackDirs = new Set<string>();
async function scan(currentPath: string, depth: number = 0): Promise<void> {
// Limit depth to prevent runaway scanning
if (depth > MAX_DEPTH) return;
let entries;
try {
entries = readdirSync(currentPath, { withFileTypes: true });
} catch (err) {
// Skip inaccessible directories
return;
}
// First pass: check for compose files in this directory
for (const pattern of COMPOSE_PATTERNS) {
const composePath = join(currentPath, pattern);
if (existsSync(composePath)) {
// Found a stack! Stack name = directory name
const stackName = normalizeStackName(basename(currentPath));
if (stackName) {
// Check for .env file
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(composePath);
discovered.push({
name: stackName,
composePath,
envPath: existsSync(envPath) ? envPath : null,
sourceDir: currentPath,
serviceCount
});
foundStackDirs.add(currentPath);
}
// Don't continue scanning in this directory - it's a stack
return;
}
}
// Second pass: check for standalone compose files (*.yml, *.yaml) and recurse into subdirectories
for (const entry of entries) {
const entryPath = join(currentPath, entry.name);
if (entry.isDirectory()) {
// Skip excluded directories
if (SKIP_DIRECTORIES.includes(entry.name)) continue;
// Skip if we already found a compose file here
if (foundStackDirs.has(entryPath)) continue;
// Recurse into subdirectory
await scan(entryPath, depth + 1);
} else if (entry.isFile()) {
const lowerName = entry.name.toLowerCase();
// Skip standard compose patterns (already handled above)
if (COMPOSE_PATTERNS.includes(entry.name)) continue;
// Check for standalone compose files (e.g., myapp.yml, myapp.yaml)
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
// Validate it's actually a compose file
if (await isComposeFile(entryPath)) {
const stackName = normalizeStackName(
entry.name.replace(/\.(yml|yaml)$/i, '')
);
if (stackName) {
// Check for .env file in same directory
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(entryPath);
discovered.push({
name: stackName,
composePath: entryPath,
envPath: existsSync(envPath) ? envPath : null,
sourceDir: currentPath,
serviceCount
});
}
}
}
}
}
}
await scan(absolutePath);
return { stacks: discovered, errors };
}
/**
* Adopt a single stack into the database
* - Checks if stack already exists (by composePath)
* - Creates stackSource record with sourceType: 'internal'
* - Does NOT deploy - just registers the stack
*/
export async function adoptStack(
stack: DiscoveredStack,
environmentId: number
): Promise<{ success: boolean; adoptedName?: string; error?: string }> {
// Get all existing stack sources to check for duplicates
const existingSources = await getStackSources();
// Check if already adopted (by composePath)
const alreadyAdopted = existingSources.some(
(s) => s.composePath === stack.composePath
);
if (alreadyAdopted) {
return { success: false, error: 'Already adopted' };
}
// Check for name conflict within the same environment
let finalName = stack.name;
const existingNames = new Set(
existingSources
.filter((s) => s.environmentId === environmentId)
.map((s) => s.stackName)
);
if (existingNames.has(finalName)) {
// Append suffix to make unique
let suffix = 1;
while (existingNames.has(`${stack.name}-${suffix}`)) {
suffix++;
}
finalName = `${stack.name}-${suffix}`;
}
// Create stack source record - use 'internal' since we know the file paths
try {
await upsertStackSource({
stackName: finalName,
environmentId,
sourceType: 'internal' as StackSourceType,
composePath: stack.composePath,
envPath: stack.envPath
});
return { success: true, adoptedName: finalName };
} catch (err) {
console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, err);
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Adopt multiple selected stacks into the database
*/
export async function adoptSelectedStacks(
stacks: DiscoveredStack[],
environmentId: number
): Promise<{ adopted: string[]; failed: { name: string; error: string }[] }> {
const adopted: string[] = [];
const failed: { name: string; error: string }[] = [];
for (const stack of stacks) {
const result = await adoptStack(stack, environmentId);
if (result.success && result.adoptedName) {
adopted.push(result.adoptedName);
} else {
failed.push({ name: stack.name, error: result.error || 'Unknown error' });
}
}
return { adopted, failed };
}
/**
* Scan specific paths and return discovered stacks (without adopting)
*/
export async function scanPaths(paths: string[]): Promise<ScanResult> {
if (paths.length === 0) {
return { discovered: [], adopted: [], skipped: [], errors: [] };
}
console.log(`[Stack Scanner] Scanning ${paths.length} path(s)...`);
const allDiscovered: DiscoveredStack[] = [];
const allErrors: { path: string; error: string }[] = [];
// Scan all paths
for (const path of paths) {
const { stacks, errors } = await scanPath(path);
allDiscovered.push(...stacks);
allErrors.push(...errors);
}
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
// Check which stacks are already adopted
const existingSources = await getStackSources();
const alreadyAdopted: DiscoveredStack[] = [];
const newStacks: DiscoveredStack[] = [];
for (const stack of allDiscovered) {
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
if (isAdopted) {
alreadyAdopted.push(stack);
} else {
newStacks.push(stack);
}
}
return {
discovered: newStacks,
adopted: [],
skipped: alreadyAdopted,
errors: allErrors
};
}
/**
* Scan all configured external paths and return discovered stacks (without adopting)
*/
export async function scanExternalPaths(): Promise<ScanResult> {
const paths = await getExternalStackPaths();
if (paths.length === 0) {
return { discovered: [], adopted: [], skipped: [], errors: [] };
}
console.log(`[Stack Scanner] Scanning ${paths.length} external path(s)...`);
const allDiscovered: DiscoveredStack[] = [];
const allErrors: { path: string; error: string }[] = [];
// Scan all paths
for (const path of paths) {
const { stacks, errors } = await scanPath(path);
allDiscovered.push(...stacks);
allErrors.push(...errors);
}
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
// Check which stacks are already adopted
const existingSources = await getStackSources();
const alreadyAdopted: DiscoveredStack[] = [];
const newStacks: DiscoveredStack[] = [];
for (const stack of allDiscovered) {
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
if (isAdopted) {
alreadyAdopted.push(stack);
} else {
newStacks.push(stack);
}
}
if (alreadyAdopted.length > 0) {
console.log(`[Stack Scanner] ${alreadyAdopted.length} stack(s) already adopted`);
}
if (newStacks.length > 0) {
console.log(`[Stack Scanner] ${newStacks.length} new stack(s) available for adoption`);
}
if (allErrors.length > 0) {
console.warn(`[Stack Scanner] ${allErrors.length} error(s) during scanning`);
}
return {
discovered: newStacks, // Only return stacks not yet adopted
adopted: [], // No auto-adopt anymore
skipped: alreadyAdopted,
errors: allErrors
};
}
/**
* Check if two paths overlap (one is parent/child of the other)
*/
function pathsOverlap(path1: string, path2: string): 'parent' | 'child' | 'same' | null {
const resolved1 = resolve(path1);
const resolved2 = resolve(path2);
if (resolved1 === resolved2) {
return 'same';
}
// Normalize paths with trailing slash for proper prefix matching
const normalized1 = resolved1.endsWith('/') ? resolved1 : resolved1 + '/';
const normalized2 = resolved2.endsWith('/') ? resolved2 : resolved2 + '/';
if (normalized2.startsWith(normalized1)) {
// path1 is parent of path2
return 'parent';
}
if (normalized1.startsWith(normalized2)) {
// path1 is child of path2
return 'child';
}
return null;
}
/**
* Validate that a path exists, is a directory, and doesn't overlap with existing paths
*/
export function validatePath(
path: string,
existingPaths: string[] = []
): { valid: boolean; error?: string; resolvedPath?: string } {
if (!path || typeof path !== 'string') {
return { valid: false, error: 'Path is required' };
}
const resolvedPath = resolve(path.trim());
if (!existsSync(resolvedPath)) {
return { valid: false, error: 'Path does not exist' };
}
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, error: 'Path is not a directory' };
}
} catch {
return { valid: false, error: 'Cannot access path' };
}
// Check for overlapping paths
for (const existingPath of existingPaths) {
const overlap = pathsOverlap(resolvedPath, existingPath);
if (overlap === 'same') {
return { valid: false, error: 'This location is already added' };
}
if (overlap === 'parent') {
return { valid: false, error: `This path contains an existing location: ${existingPath}` };
}
if (overlap === 'child') {
return { valid: false, error: `This path is inside an existing location: ${existingPath}` };
}
}
return { valid: true, resolvedPath };
}
/**
* Detect which discovered stacks are already running on any environment.
* Matches by stack name (com.docker.compose.project label) since paths may differ.
*/
export async function detectRunningStacks(
discovered: DiscoveredStack[]
): Promise<DiscoveredStack[]> {
if (discovered.length === 0) {
return discovered;
}
// Dynamic imports to avoid circular dependencies
const { listComposeStacks } = await import('./stacks.js');
const { getEnvironments } = await import('./db.js');
// Get all environments
const environments = await getEnvironments();
if (environments.length === 0) {
return discovered;
}
// Build map of stack name -> running info across all environments
const runningStacksMap = new Map<string, RunningStackInfo[]>();
// Query each environment in parallel for running stacks
await Promise.all(
environments.map(async (env) => {
try {
const stacks = await listComposeStacks(env.id);
for (const stack of stacks) {
const existing = runningStacksMap.get(stack.name) || [];
existing.push({
envId: env.id,
envName: env.name,
containerCount: stack.containers?.length || 0
});
runningStacksMap.set(stack.name, existing);
}
} catch (error) {
// Environment might be offline - skip silently
console.warn(`[Stack Scanner] Failed to query environment ${env.name}:`, error);
}
})
);
// Attach running info to discovered stacks by matching name
return discovered.map((stack) => ({
...stack,
runningOn: runningStacksMap.get(stack.name)
}));
}
+919 -135
View File
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -102,7 +102,12 @@ export interface ShutdownCommand {
type: 'shutdown';
}
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand;
export interface UpdateIntervalCommand {
type: 'update_interval';
intervalMs: number;
}
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand | UpdateIntervalCommand;
// Subprocess configuration
interface SubprocessConfig {
@@ -198,6 +203,20 @@ class SubprocessManager {
this.sendToEvents({ type: 'refresh_environments' });
}
/**
* Send message to metrics subprocess
*/
sendToMetricsSubprocess(message: MainProcessCommand): void {
this.sendToMetrics(message);
}
/**
* Send message to events subprocess
*/
sendToEventsSubprocess(message: MainProcessCommand): void {
this.sendToEvents(message);
}
/**
* Start the metrics collection subprocess
*/
@@ -591,3 +610,21 @@ export function refreshSubprocessEnvironments(): void {
manager.refreshEnvironments();
}
}
/**
* Send message to event subprocess
*/
export function sendToEventSubprocess(message: MainProcessCommand): void {
if (manager) {
manager.sendToEventsSubprocess(message);
}
}
/**
* Send message to metrics subprocess
*/
export function sendToMetricsSubprocess(message: MainProcessCommand): void {
if (manager) {
manager.sendToMetricsSubprocess(message);
}
}
+210 -13
View File
@@ -7,7 +7,7 @@
* Communication with main process via IPC (process.send).
*/
import { getEnvironments, type ContainerEventAction } from '../db';
import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
import { getDockerEvents } from '../docker';
import type { MainProcessCommand } from '../subprocess-manager';
@@ -19,9 +19,15 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max
// Only send notifications on status CHANGES, not on every reconnect attempt
const environmentOnlineStatus: Map<number, boolean> = new Map();
// Active collectors per environment
// Active collectors per environment (for streaming mode)
const collectors: Map<number, AbortController> = 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
@@ -30,6 +36,10 @@ const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds
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',
@@ -134,10 +144,16 @@ function processEvent(event: DockerEvent, envId: number) {
if (event.Type !== 'container') return;
// Map Docker action to our action type
const action = event.Action.split(':')[0] as ContainerEventAction;
// 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(action)) return;
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;
@@ -169,14 +185,17 @@ function processEvent(event: DockerEvent, envId: number) {
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
// Prepare notification data
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
// 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 === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';
@@ -202,6 +221,76 @@ function processEvent(event: DockerEvent, envId: number) {
});
}
/**
* 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 {
reader.releaseLock();
} catch {
// Reader already released
}
}
// 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
*/
@@ -323,7 +412,42 @@ async function startEnvironmentCollector(envId: number, envName: string) {
}
/**
* Stop collecting events for a specific environment
* 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 controller = collectors.get(envId);
@@ -342,6 +466,21 @@ async function refreshEventCollectors() {
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(
@@ -353,18 +492,55 @@ async function refreshEventCollectors() {
// Stop collectors for removed environments or those with collection disabled
for (const envId of collectors.keys()) {
if (!activeEnvIds.has(envId)) {
console.log(`[EventSubprocess] Stopping collector for environment ${envId}`);
console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
stopEnvironmentCollector(envId);
}
}
// Start collectors for environments with collection enabled
// 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);
}
}
// Start collectors based on mode
for (const env of environments) {
// Skip Hawser Edge (handled by main process)
if (env.connectionType === 'hawser-edge') continue;
if (env.collectActivity && !collectors.has(env.id)) {
startEnvironmentCollector(env.id, env.name);
// 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) {
@@ -383,6 +559,13 @@ function handleCommand(command: MainProcessCommand): void {
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();
@@ -402,11 +585,16 @@ function shutdown(): void {
cacheCleanupInterval = null;
}
// Stop all environment collectors
// 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();
@@ -420,6 +608,15 @@ function shutdown(): void {
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) {
console.error('[EventSubprocess] Failed to load settings, using defaults:', error);
}
// Start collectors for all environments
await refreshEventCollectors();
@@ -7,12 +7,12 @@
* Communication with main process via IPC (process.send).
*/
import { getEnvironments, getEnvSetting } from '../db';
import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
import os from 'node:os';
import type { MainProcessCommand } from '../subprocess-manager';
const COLLECT_INTERVAL = 10000; // 10 seconds
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
@@ -82,12 +82,16 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
}
// Get container memory usage (subtract cache for actual usage)
// 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 memCache = stats.memory_stats?.stats?.cache || 0;
const actualMemUsed = memUsage - memCache;
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 > 0 ? actualMemUsed : memUsage };
return { cpuPercent, memUsage: actualMemUsed };
} catch {
return { cpuPercent: 0, memUsage: 0 };
}
@@ -356,6 +360,16 @@ function handleCommand(command: MainProcessCommand): void {
// 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();
@@ -386,8 +400,15 @@ function shutdown(): void {
/**
* Start the metrics collector
*/
function start(): void {
console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...');
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();

Some files were not shown because too many files have changed in this diff Show More