mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 03:20:43 +03:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6382b4083e | |||
| b269b8d50d | |||
| 410d542c58 | |||
| a02115e6bc | |||
| 86e4c9eb56 | |||
| c46870afd1 | |||
| a8a5623c10 | |||
| 059ecbb1dc | |||
| 3eab42169c | |||
| 6a7116a5b7 | |||
| 215f52b1f0 | |||
| de62327a07 | |||
| cd6544aedb | |||
| c60db2930c | |||
| 695acd922e | |||
| fcb36c4646 | |||
| 53ca99ac77 | |||
| 81fcc28d0b | |||
| 522154cd68 | |||
| 9db6e67a61 | |||
| ba05d16d79 | |||
| f4a57ecfd3 | |||
| ab8743bdae | |||
| e536388a7a | |||
| 497fbdb635 | |||
| 53d60fdddd |
@@ -0,0 +1,3 @@
|
||||
buy_me_a_coffee:
|
||||
displayName: "Buy Me a Coffee"
|
||||
account: dockhand
|
||||
@@ -0,0 +1,2 @@
|
||||
.idea/
|
||||
.DS_Store
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# 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 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 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 ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
|
||||
# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM
|
||||
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build
|
||||
|
||||
# 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
|
||||
|
||||
# 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 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 --chown=dockhand:dockhand drizzle/ ./drizzle/
|
||||
COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy entrypoint script (root-owned, executable)
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts
|
||||
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create data directories with correct ownership
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["bun", "run", "./build/index.js"]
|
||||
+1
-1
@@ -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
@@ -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.
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="images/logo.webp" alt="Dockhand" width="300">
|
||||
<img src="src/images/logo.webp" alt="Dockhand" width="300">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -7,8 +7,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://dockhand.io">Website</a> •
|
||||
<a href="https://dockhand.io/docs">Documentation</a> •
|
||||
<a href="https://dockhand.pro">Website</a> •
|
||||
<a href="https://dockhand.pro/manual">Documentation</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
@@ -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,10 +30,11 @@ 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
|
||||
- **Docker**: Dockerode library
|
||||
- **Docker**: direct docker API calls.
|
||||
|
||||
## License
|
||||
|
||||
@@ -47,10 +48,18 @@ 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.io](https://dockhand.io)
|
||||
- **Documentation**: [https://dockhand.io/docs](https://dockhand.io/docs)
|
||||
- **Website**: [https://dockhand.pro](https://dockhand.pro)
|
||||
- **Documentation**: [https://dockhand.pro/manual](https://dockhand.pro/manual)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Bun configuration for Dockhand
|
||||
|
||||
[install]
|
||||
# Use exact versions for reproducible builds
|
||||
exact = true
|
||||
|
||||
[run]
|
||||
# Enable source maps for better error messages
|
||||
sourcemap = "external"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Dockhand Docker Entrypoint
|
||||
# === Configuration ===
|
||||
PUID=${PUID:-1001}
|
||||
PGID=${PGID:-1001}
|
||||
|
||||
# === Detect if running as root ===
|
||||
RUNNING_AS_ROOT=false
|
||||
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
|
||||
echo "Running as root user (PUID=0)"
|
||||
RUN_USER="root"
|
||||
elif [ "$RUNNING_AS_ROOT" = "true" ] && [ "$PUID" = "1001" ] && [ "$PGID" = "1001" ]; then
|
||||
echo "Running as root user"
|
||||
RUN_USER="root"
|
||||
else
|
||||
RUN_USER="dockhand"
|
||||
# Only modify if PUID/PGID differ from image defaults (1001:1001)
|
||||
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
|
||||
echo "Configuring user with PUID=$PUID PGID=$PGID"
|
||||
|
||||
# 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)
|
||||
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
|
||||
if getent group "$PGID" >/dev/null 2>&1; then
|
||||
TARGET_GROUP=$(getent group "$PGID" | cut -d: -f1)
|
||||
else
|
||||
addgroup -g "$PGID" dockhand
|
||||
TARGET_GROUP="dockhand"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_USER_CREATE" = "false" ]; then
|
||||
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Directory Ownership ===
|
||||
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 "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Docker Socket Access (Optional) ===
|
||||
# Check if Docker socket is mounted and accessible
|
||||
# Note: DOCKER_HOST with tcp:// requires configuring an environment via the web UI
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
# Socket exists - check if readable
|
||||
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 $RUN_USER user"
|
||||
echo ""
|
||||
echo "To use local Docker, fix with one of these options:"
|
||||
echo ""
|
||||
echo " 1. Add container to docker group (GID: $SOCKET_GID):"
|
||||
echo " docker run --group-add $SOCKET_GID ..."
|
||||
echo ""
|
||||
echo " 2. Use a socket proxy:"
|
||||
echo " Configure a 'direct' environment pointing to tcp://socket-proxy:2375"
|
||||
echo ""
|
||||
echo " 3. Make socket world-readable (less secure):"
|
||||
echo " chmod 666 /var/run/docker.sock"
|
||||
echo ""
|
||||
echo "Continuing startup - configure environments via the web UI..."
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
else
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
fi
|
||||
|
||||
# === Detect Docker Host Hostname (for license validation) ===
|
||||
# Query Docker API to get the real host hostname (not container ID)
|
||||
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
|
||||
else
|
||||
echo "Using configured hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
else
|
||||
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 ===
|
||||
if [ "$RUN_USER" = "root" ]; then
|
||||
# Running as root - execute directly
|
||||
if [ "$1" = "" ]; then
|
||||
exec bun run ./build/index.js
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
else
|
||||
# Running as non-root user
|
||||
echo "Running as user: $RUN_USER"
|
||||
if [ "$1" = "" ]; then
|
||||
exec su-exec "$RUN_USER" bun run ./build/index.js
|
||||
else
|
||||
exec su-exec "$RUN_USER" "$@"
|
||||
fi
|
||||
fi
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const isPostgres = databaseUrl && (databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://'));
|
||||
|
||||
export default defineConfig({
|
||||
// Use different schema files for SQLite vs PostgreSQL
|
||||
schema: isPostgres
|
||||
? './src/lib/server/db/schema/pg-schema.ts'
|
||||
: './src/lib/server/db/schema/index.ts',
|
||||
out: isPostgres ? './drizzle-pg' : './drizzle',
|
||||
dialect: isPostgres ? 'postgresql' : 'sqlite',
|
||||
dbCredentials: isPostgres
|
||||
? { url: databaseUrl! }
|
||||
: { url: `file:${process.env.DATA_DIR || './data'}/dockhand.db` }
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,236 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { Plus, Info, Upload, Trash2 } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
variables: EnvVar[];
|
||||
validation?: ValidationResult | null;
|
||||
readonly?: boolean;
|
||||
showSource?: boolean;
|
||||
sources?: Record<string, 'file' | 'override'>;
|
||||
placeholder?: { key: string; value: string };
|
||||
infoText?: string;
|
||||
existingSecretKeys?: Set<string>;
|
||||
class?: string;
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
variables = $bindable(),
|
||||
validation = null,
|
||||
readonly = false,
|
||||
showSource = false,
|
||||
sources = {},
|
||||
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
|
||||
infoText,
|
||||
existingSecretKeys = new Set<string>(),
|
||||
class: className = '',
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputRef: HTMLInputElement;
|
||||
|
||||
function addEnvVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
}
|
||||
|
||||
function handleLoadFromFile() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string): EnvVar[] {
|
||||
const lines = content.split('\n');
|
||||
const envVars: EnvVar[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines and comments
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=VALUE format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
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?.();
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function clearAllVariables() {
|
||||
variables = [];
|
||||
}
|
||||
|
||||
// Count of non-empty variables
|
||||
const hasVariables = $derived(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>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !readonly}
|
||||
<div class="flex items-center gap-1">
|
||||
<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
|
||||
</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">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
accept=".env,.env.*,text/plain"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
{/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}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Add missing variables -->
|
||||
{#if 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 }];
|
||||
}}
|
||||
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}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Variables list -->
|
||||
<div class="flex-1 overflow-auto px-4 py-3">
|
||||
<StackEnvVarsEditor
|
||||
bind:variables
|
||||
{validation}
|
||||
{readonly}
|
||||
{showSource}
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+118
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
"prebuild": "bunx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
|
||||
"build": "bunx --bun vite build && bun scripts/patch-build.ts && bun scripts/build-subprocesses.ts",
|
||||
"start": "bun ./build/index.js",
|
||||
"preview": "bun ./build/index.js",
|
||||
"prepare": "bunx --bun svelte-kit sync || echo ''",
|
||||
"check": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "bun test",
|
||||
"test:smoke": "bun test tests/api-smoke.test.ts",
|
||||
"test:containers": "bun test tests/container-lifecycle.test.ts",
|
||||
"test:notifications": "bun test tests/notifications.test.ts",
|
||||
"test:hawser": "bun test tests/hawser-connection.test.ts",
|
||||
"test:build": "SKIP_BUILD_TEST=1 bun test tests/build.test.ts",
|
||||
"test:postgres": "bun test tests/database-postgres.test.ts",
|
||||
"test:crud": "bun test tests/crud-operations.test.ts",
|
||||
"test:scheduling": "bun test tests/scheduling.test.ts",
|
||||
"test:images": "bun test tests/images.test.ts",
|
||||
"test:volumes": "bun test tests/volumes-networks.test.ts",
|
||||
"test:stacks": "bun test tests/stacks.test.ts",
|
||||
"test:stacks:matrix": "bun test tests/stack-matrix.test.ts",
|
||||
"test:stacks:git": "bun test tests/stack-git-flow.test.ts",
|
||||
"test:stacks:env": "bun test tests/stack-env-vars.test.ts",
|
||||
"test:stacks:all": "bun test tests/stack-*.test.ts tests/stacks.test.ts",
|
||||
"test:files": "bun test tests/container-files.test.ts",
|
||||
"test:license": "bun test tests/license.test.ts",
|
||||
"test:activity": "bun test tests/activity-dashboard.test.ts",
|
||||
"test:all": "bun test tests/",
|
||||
"test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts",
|
||||
"test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts",
|
||||
"test:e2e": "bunx playwright test tests/e2e/",
|
||||
"generate:legal": "bun scripts/generate-legal-pages.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@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.1",
|
||||
"hash-wasm": "4.12.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ldapts": "^8.1.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
"otpauth": "^9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "0.9.69",
|
||||
"svelte-sonner": "1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@layerstack/tailwind": "^1.0.1",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@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.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",
|
||||
"cytoscape": "^3.33.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"layerchart": "^1.0.13",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.46.1",
|
||||
"svelte-adapter-bun": "1.0.1",
|
||||
"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.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/stacks/[name]/env?env=X
|
||||
* Get all environment variables for a stack.
|
||||
* Secrets are masked with '***' in the response.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : null;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Environment access check (enterprise only)
|
||||
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const variables = await getStackEnvVars(stackName, envIdNum, true);
|
||||
|
||||
return json({
|
||||
variables: variables.map(v => ({
|
||||
key: v.key,
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting stack env vars:', error);
|
||||
return json({ error: 'Failed to get environment variables' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/stacks/[name]/env?env=X
|
||||
* Set/replace all environment variables for a stack.
|
||||
* Body: { variables: [{ key, value, isSecret? }] }
|
||||
*
|
||||
* Note: For secrets, if the value is '***' (the masked placeholder), the original
|
||||
* secret value from the database is preserved instead of overwriting with '***'.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : null;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'edit', envIdNum ?? undefined)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Environment access check (enterprise only)
|
||||
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.variables || !Array.isArray(body.variables)) {
|
||||
return json({ error: 'Invalid request body: variables array required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate variables
|
||||
for (const v of body.variables) {
|
||||
if (!v.key || typeof v.key !== 'string') {
|
||||
return json({ error: 'Invalid variable: key is required and must be a string' }, { status: 400 });
|
||||
}
|
||||
if (typeof v.value !== 'string') {
|
||||
return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 });
|
||||
}
|
||||
// Validate key format (env var naming convention)
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) {
|
||||
return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any secrets have the masked placeholder '***'
|
||||
// If so, we need to preserve their original values from the database
|
||||
const secretsWithMaskedValue = body.variables.filter(
|
||||
(v: { key: string; value: string; isSecret?: boolean }) =>
|
||||
v.isSecret && v.value === '***'
|
||||
);
|
||||
|
||||
let variablesToSave = body.variables;
|
||||
|
||||
if (secretsWithMaskedValue.length > 0) {
|
||||
// Get existing variables (unmasked) to preserve secret values
|
||||
const existingVars = await getStackEnvVars(stackName, envIdNum, false);
|
||||
const existingByKey = new Map(existingVars.map(v => [v.key, v]));
|
||||
|
||||
// Replace masked secrets with their original values
|
||||
variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => {
|
||||
if (v.isSecret && v.value === '***') {
|
||||
const existing = existingByKey.get(v.key);
|
||||
if (existing && existing.isSecret) {
|
||||
// Preserve the original secret value
|
||||
return { ...v, value: existing.value };
|
||||
}
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
await setStackEnvVars(stackName, envIdNum, variablesToSave);
|
||||
|
||||
return json({ success: true, count: variablesToSave.length });
|
||||
} catch (error) {
|
||||
console.error('Error setting stack env vars:', error);
|
||||
return json({ error: 'Failed to set environment variables' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert } from 'lucide-svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), qrCode, secret, userId, onClose, onSuccess }: Props = $props();
|
||||
|
||||
let token = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
function resetForm() {
|
||||
token = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function verifyAndEnableMfa() {
|
||||
if (!token) {
|
||||
error = 'Please enter the verification code';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/mfa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'verify', token })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
error = data.error || 'Invalid verification code';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to verify MFA';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { resetForm(); focusFirstInput(); } else onClose(); }}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<QrCode class="w-5 h-5" />
|
||||
Setup two-factor authentication
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
|
||||
{#if qrCode}
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={qrCode} alt="MFA QR Code" class="w-48 h-48" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">Or enter this code manually:</Label>
|
||||
<code class="block p-2 bg-muted rounded text-sm font-mono break-all">{secret}</code>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Verification code</Label>
|
||||
<Input
|
||||
bind:value={token}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength={6}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the code from your authenticator app to verify setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={verifyAndEnableMfa} disabled={loading || !token}>
|
||||
{#if loading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<ShieldCheck class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Enable MFA
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,740 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable } from 'lucide-svelte';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
stackName?: string; // Required for edit mode, optional for create
|
||||
onClose: () => void;
|
||||
onSuccess: () => void; // Called after create or save
|
||||
}
|
||||
|
||||
let { open = $bindable(), mode, stackName = '', onClose, onSuccess }: Props = $props();
|
||||
|
||||
// Form state
|
||||
let newStackName = $state('');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let errors = $state<{ stackName?: string; compose?: string }>({});
|
||||
let composeContent = $state('');
|
||||
let originalContent = $state('');
|
||||
let activeTab = $state<'editor' | 'graph'>('editor');
|
||||
let showConfirmClose = $state(false);
|
||||
let editorTheme = $state<'light' | 'dark'>('dark');
|
||||
|
||||
// Environment variables state
|
||||
let envVars = $state<EnvVar[]>([]);
|
||||
let originalEnvVars = $state<EnvVar[]>([]);
|
||||
let envValidation = $state<ValidationResult | null>(null);
|
||||
let validating = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
|
||||
// CodeEditor reference for explicit marker updates
|
||||
let codeEditorRef: CodeEditor | null = $state(null);
|
||||
|
||||
// ComposeGraphViewer reference for resize on panel toggle
|
||||
let graphViewerRef: ComposeGraphViewer | null = $state(null);
|
||||
|
||||
// Debounce timer for validation
|
||||
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const defaultCompose = `version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- APP_ENV=\${APP_ENV:-production}
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Add more services as needed
|
||||
# networks:
|
||||
# default:
|
||||
# driver: bridge
|
||||
`;
|
||||
|
||||
// Count of defined environment variables (with non-empty keys)
|
||||
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
|
||||
|
||||
// Build a lookup map from envVars for quick access
|
||||
const envVarMap = $derived.by(() => {
|
||||
const map = new Map<string, { value: string; isSecret: boolean }>();
|
||||
for (const v of envVars) {
|
||||
if (v.key.trim()) {
|
||||
map.set(v.key.trim(), { value: v.value, isSecret: v.isSecret });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Compute variable markers for the code editor (with values for overlay)
|
||||
const variableMarkers = $derived.by<VariableMarker[]>(() => {
|
||||
if (!envValidation) return [];
|
||||
|
||||
const markers: VariableMarker[] = [];
|
||||
|
||||
// Add missing required variables
|
||||
for (const name of envValidation.missing) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'missing',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
// Add defined required variables
|
||||
for (const name of envValidation.required) {
|
||||
if (!envValidation.missing.includes(name)) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'required',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional variables
|
||||
for (const name of envValidation.optional) {
|
||||
const env = envVarMap.get(name);
|
||||
markers.push({
|
||||
name,
|
||||
type: 'optional',
|
||||
value: env?.value,
|
||||
isSecret: env?.isSecret
|
||||
});
|
||||
}
|
||||
|
||||
return markers;
|
||||
});
|
||||
|
||||
// Check for compose changes
|
||||
const hasComposeChanges = $derived(composeContent !== originalContent);
|
||||
|
||||
// Stable callback for compose content changes - avoids stale closure issues
|
||||
function handleComposeChange(value: string) {
|
||||
composeContent = value;
|
||||
debouncedValidate();
|
||||
}
|
||||
|
||||
// Debounced validation to avoid too many API calls while typing
|
||||
function debouncedValidate() {
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
validateTimer = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Explicitly push markers to the editor
|
||||
function updateEditorMarkers() {
|
||||
if (!codeEditorRef) return;
|
||||
codeEditorRef.updateVariableMarkers(variableMarkers);
|
||||
}
|
||||
|
||||
// Check for env var changes (compare by serializing)
|
||||
const hasEnvVarChanges = $derived.by(() => {
|
||||
const current = JSON.stringify(envVars.filter(v => v.key));
|
||||
const original = JSON.stringify(originalEnvVars);
|
||||
return current !== original;
|
||||
});
|
||||
|
||||
const hasChanges = $derived(hasComposeChanges || hasEnvVarChanges);
|
||||
|
||||
// Display title
|
||||
const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack'));
|
||||
|
||||
onMount(() => {
|
||||
// Follow app theme from localStorage
|
||||
const appTheme = localStorage.getItem('theme');
|
||||
if (appTheme === 'dark' || appTheme === 'light') {
|
||||
editorTheme = appTheme;
|
||||
} else {
|
||||
// Fallback to system preference
|
||||
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadComposeFile() {
|
||||
if (mode !== 'edit' || !stackName) return;
|
||||
|
||||
loading = true;
|
||||
loadError = null;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Load compose file
|
||||
const response = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/compose`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to load compose file');
|
||||
}
|
||||
|
||||
composeContent = data.content;
|
||||
originalContent = data.content;
|
||||
|
||||
// Load environment variables
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
|
||||
if (envResponse.ok) {
|
||||
const envData = await envResponse.json();
|
||||
envVars = envData.variables || [];
|
||||
originalEnvVars = JSON.parse(JSON.stringify(envData.variables || []));
|
||||
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
|
||||
existingSecretKeys = new Set(
|
||||
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateEnvVars() {
|
||||
const content = composeContent || defaultCompose;
|
||||
if (!content.trim()) return;
|
||||
|
||||
validating = true;
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
// Use 'new' as placeholder stack name for new stacks
|
||||
const stackNameForValidation = mode === 'edit' ? stackName : (newStackName.trim() || 'new');
|
||||
// Pass current UI env vars for validation
|
||||
const currentVars = envVars.filter(v => v.key.trim()).map(v => v.key.trim());
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackNameForValidation)}/env/validate`, envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ compose: content, variables: currentVars })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
envValidation = await response.json();
|
||||
// Explicitly update markers in the editor after validation
|
||||
// Use setTimeout to ensure derived variableMarkers has updated
|
||||
setTimeout(() => updateEditorMarkers(), 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to validate env vars:', e);
|
||||
} finally {
|
||||
validating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditorTheme() {
|
||||
editorTheme = editorTheme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('dockhand-editor-theme', editorTheme);
|
||||
}
|
||||
|
||||
function handleGraphContentChange(newContent: string) {
|
||||
composeContent = newContent;
|
||||
}
|
||||
|
||||
async function handleCreate(start: boolean = false) {
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!newStackName.trim()) {
|
||||
errors.stackName = 'Stack name is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
const content = composeContent || defaultCompose;
|
||||
if (!content.trim()) {
|
||||
errors.compose = 'Compose file content is required';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Create the stack
|
||||
const response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newStackName.trim(),
|
||||
compose: content,
|
||||
start
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create stack');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0) {
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(newStackName.trim())}/env`, envId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(restart = false) {
|
||||
errors = {};
|
||||
|
||||
if (!composeContent.trim()) {
|
||||
errors.compose = 'Compose file content cannot be empty';
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Save compose file
|
||||
const response = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: composeContent,
|
||||
restart
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to save compose file');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0 || originalEnvVars.length > 0) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
originalContent = composeContent;
|
||||
originalEnvVars = JSON.parse(JSON.stringify(definedVars));
|
||||
onSuccess();
|
||||
|
||||
if (!restart) {
|
||||
// Show success briefly then close
|
||||
setTimeout(() => handleClose(), 500);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (hasChanges) {
|
||||
showConfirmClose = true;
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// Clear any pending validation timer
|
||||
if (validateTimer) {
|
||||
clearTimeout(validateTimer);
|
||||
validateTimer = null;
|
||||
}
|
||||
// Reset all state
|
||||
newStackName = '';
|
||||
error = null;
|
||||
loadError = null;
|
||||
errors = {};
|
||||
composeContent = '';
|
||||
originalContent = '';
|
||||
envVars = [];
|
||||
originalEnvVars = [];
|
||||
envValidation = null;
|
||||
existingSecretKeys = new Set();
|
||||
activeTab = 'editor';
|
||||
showConfirmClose = false;
|
||||
codeEditorRef = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function discardAndClose() {
|
||||
showConfirmClose = false;
|
||||
handleClose();
|
||||
}
|
||||
|
||||
// Initialize when dialog opens - ONLY ONCE per open
|
||||
let hasInitialized = $state(false);
|
||||
$effect(() => {
|
||||
if (open && !hasInitialized) {
|
||||
hasInitialized = true;
|
||||
if (mode === 'edit' && stackName) {
|
||||
loadComposeFile().then(() => {
|
||||
// Auto-validate after loading
|
||||
validateEnvVars();
|
||||
});
|
||||
} else if (mode === 'create') {
|
||||
// Set default compose content for create mode
|
||||
composeContent = defaultCompose;
|
||||
originalContent = defaultCompose; // Track original for change detection
|
||||
loading = false;
|
||||
// Auto-validate default compose
|
||||
validateEnvVars();
|
||||
}
|
||||
} else if (!open) {
|
||||
hasInitialized = false; // Reset when modal closes
|
||||
}
|
||||
});
|
||||
|
||||
// Re-validate when envVars change (adding/removing variables affects missing/defined status)
|
||||
$effect(() => {
|
||||
// Track envVars changes (this triggers on any modification to envVars array)
|
||||
const vars = envVars;
|
||||
if (!open || !envValidation) return;
|
||||
|
||||
// Debounce to avoid too many API calls while typing
|
||||
const timeout = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) {
|
||||
focusFirstInput();
|
||||
} else {
|
||||
// Prevent closing if there are unsaved changes - show confirmation instead
|
||||
if (hasChanges) {
|
||||
// Re-open the dialog and show confirmation
|
||||
open = true;
|
||||
showConfirmClose = true;
|
||||
}
|
||||
// If no changes, let it close naturally
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-7xl w-[95vw] h-[90vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700" showCloseButton={false}>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{#if mode === 'create'}
|
||||
Create compose stack
|
||||
{:else}
|
||||
{stackName}
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if mode === 'create'}
|
||||
Create a new Docker Compose stack
|
||||
{:else}
|
||||
Edit compose file and view stack structure
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5 ml-3">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'editor' ? 'bg-white dark:bg-zinc-900 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={() => activeTab = 'editor'}
|
||||
>
|
||||
<Code class="w-3.5 h-3.5" />
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'graph' ? 'bg-white dark:bg-zinc-900 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={() => activeTab = 'graph'}
|
||||
>
|
||||
<GitGraph class="w-3.5 h-3.5" />
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Theme toggle (only in editor mode) -->
|
||||
{#if activeTab === 'editor'}
|
||||
<button
|
||||
onclick={toggleEditorTheme}
|
||||
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
title={editorTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
|
||||
>
|
||||
{#if editorTheme === 'light'}
|
||||
<Moon class="w-4 h-4" />
|
||||
{:else}
|
||||
<Sun class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={tryClose}
|
||||
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if errors.compose}
|
||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{errors.compose}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'edit' && loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="flex items-center gap-3 text-zinc-400 dark:text-zinc-500">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
<span>Loading compose file...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'edit' && loadError}
|
||||
<div class="flex-1 flex items-center justify-center p-6">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle class="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium mb-2">Could not load compose file</h3>
|
||||
<p class="text-sm text-zinc-400 dark:text-zinc-500 mb-4">{loadError}</p>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
This stack may have been created outside of Dockhand or the compose file may have been moved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stack name input (create mode only) -->
|
||||
{#if mode === 'create'}
|
||||
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div class="max-w-md space-y-1">
|
||||
<Label for="stack-name">Stack name</Label>
|
||||
<Input
|
||||
id="stack-name"
|
||||
bind:value={newStackName}
|
||||
placeholder="my-stack"
|
||||
class={errors.stackName ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => errors.stackName = undefined}
|
||||
/>
|
||||
{#if errors.stackName}
|
||||
<p class="text-xs text-destructive">{errors.stackName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if activeTab === 'editor'}
|
||||
<!-- Editor tab: Code editor + Env panel side by side -->
|
||||
<div class="w-[60%] flex-shrink-0 border-r border-zinc-200 dark:border-zinc-700 flex flex-col min-w-0">
|
||||
{#if open}
|
||||
<div class="flex-1 p-3 min-h-0">
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
theme={editorTheme}
|
||||
onchange={handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Environment variables panel -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-700 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
<Variable class="w-3.5 h-3.5" />
|
||||
Environment variables
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => validateEnvVars()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'graph'}
|
||||
<!-- Graph tab: Full width -->
|
||||
<ComposeGraphViewer
|
||||
bind:this={graphViewerRef}
|
||||
composeContent={composeContent || defaultCompose}
|
||||
class="h-full flex-1"
|
||||
onContentChange={handleGraphContentChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if hasChanges}
|
||||
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
|
||||
{:else}
|
||||
No changes
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={tryClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{#if mode === 'create'}
|
||||
<!-- Create mode buttons -->
|
||||
<Button variant="outline" onclick={() => handleCreate(false)} disabled={saving}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
{:else}
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
Create
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => handleCreate(true)} disabled={saving}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
{:else}
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Create & Start
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Edit mode buttons -->
|
||||
<Button variant="outline" onclick={() => handleSave(false)} disabled={saving || !hasChanges || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={() => handleSave(true)} disabled={saving || !hasChanges || loading || !!loadError}>
|
||||
{#if saving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Applying...
|
||||
{:else}
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Save & apply
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Unsaved changes confirmation dialog -->
|
||||
<Dialog.Root bind:open={showConfirmClose}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Unsaved changes</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
You have unsaved changes. Are you sure you want to close without saving?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex justify-end gap-1.5 mt-4">
|
||||
<Button variant="outline" size="sm" onclick={() => showConfirmClose = false}>
|
||||
Continue editing
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={discardAndClose}>
|
||||
Discard changes
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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');
|
||||
Executable
+20
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+20
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+94
@@ -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"
|
||||
Executable
+17
@@ -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
|
||||
Executable
+101
@@ -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
|
||||
Executable
+75
@@ -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
|
||||
Executable
+117
@@ -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!"
|
||||
Executable
+74
@@ -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
|
||||
Executable
+94
@@ -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"
|
||||
Executable
+118
@@ -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"
|
||||
Executable
+139
@@ -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
|
||||
Executable
+117
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
Executable
+20
@@ -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
|
||||
Executable
+21
@@ -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
|
||||
Executable
+88
@@ -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
|
||||
Executable
+62
@@ -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
|
||||
Executable
+104
@@ -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!"
|
||||
Executable
+61
@@ -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
|
||||
Executable
+80
@@ -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"
|
||||
Executable
+73
@@ -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"
|
||||
Executable
+123
@@ -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
|
||||
Executable
+106
@@ -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
|
||||
@@ -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}`);
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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">← Back to home</a>
|
||||
</header>
|
||||
|
||||
<h1>${title}</h1>
|
||||
|
||||
<div class="content">
|
||||
<pre>${escapeHtml(content)}</pre>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2025-2026 Finsys / Jarek Krochmalski · <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');
|
||||
@@ -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
@@ -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
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
Vendored
@@ -13,5 +13,3 @@
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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 + '/'));
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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;
|
||||
@@ -453,6 +501,9 @@
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
case 'dotenv':
|
||||
case 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -542,6 +593,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 +609,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(),
|
||||
@@ -594,18 +654,26 @@
|
||||
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) {
|
||||
// Defer callback to next microtask to avoid blocking input handling
|
||||
// This allows key repeat to work properly
|
||||
const newContent = lastChangingTr.newDoc.toString();
|
||||
queueMicrotask(() => {
|
||||
if (onchangeRef) {
|
||||
onchangeRef(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,7 +683,6 @@
|
||||
dispatchTransactions
|
||||
});
|
||||
|
||||
|
||||
// Push initial markers if provided
|
||||
if (variableMarkers.length > 0) {
|
||||
view.dispatch({
|
||||
@@ -625,11 +692,16 @@
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
markerUpdateTimer = null;
|
||||
}
|
||||
if (view) {
|
||||
view.destroy();
|
||||
view = null;
|
||||
}
|
||||
initialized = false;
|
||||
lastAppliedMarkersJson = '';
|
||||
}
|
||||
|
||||
// Get current editor content
|
||||
@@ -656,11 +728,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 +789,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 +819,6 @@
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full overflow-hidden {className}"
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
@@ -61,7 +61,6 @@
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm);
|
||||
onConfirm();
|
||||
open = false;
|
||||
onOpenChange(false);
|
||||
@@ -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
@@ -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}-${variable.key}`)}
|
||||
{@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>
|
||||
@@ -0,0 +1,463 @@
|
||||
<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, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
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;
|
||||
sources?: Record<string, 'file' | 'override'>;
|
||||
placeholder?: { key: string; value: string };
|
||||
infoText?: string;
|
||||
existingSecretKeys?: Set<string>;
|
||||
theme?: 'light' | 'dark';
|
||||
class?: string;
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
variables = $bindable([]),
|
||||
rawContent = $bindable(''),
|
||||
validation = null,
|
||||
readonly = false,
|
||||
showSource = false,
|
||||
sources = {},
|
||||
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[]>([]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
lineNum++;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
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);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
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 { 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) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
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);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
rawContent = '';
|
||||
variables = [];
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
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">
|
||||
<!-- 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.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 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
|
||||
</Button>
|
||||
{#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>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
accept=".env,.env.*,text/plain"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- 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}
|
||||
<!-- 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={() => 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}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- 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>
|
||||
@@ -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]">
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user